2.3.0 RC 2 (Very WIP)

This commit is contained in:
Stephen B 2025-09-22 22:33:20 -04:00
parent 34532c7f6a
commit e90ba11535
16 changed files with 2008 additions and 914 deletions

View file

@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */
17C744F02E20BED000834F17 /* Pipify in Frameworks */ = {isa = PBXBuildFile; productRef = 17C744EF2E20BED000834F17 /* Pipify */; };
68AA2E4D2E8214A1008342E8 /* StikDebug.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 68AA2E4C2E8214A1008342E8 /* StikDebug.storekit */; };
68AD80462E6B65DA00DE9112 /* ZsignSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 68AD80452E6B65DA00DE9112 /* ZsignSwift */; };
68AD80482E6B65DA00DE9112 /* zsign in Frameworks */ = {isa = PBXBuildFile; productRef = 68AD80472E6B65DA00DE9112 /* zsign */; };
68D569BE2E1B415700A5BA36 /* CodeEditorView in Frameworks */ = {isa = PBXBuildFile; productRef = 68D569BD2E1B415700A5BA36 /* CodeEditorView */; };
@ -68,6 +69,7 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
68AA2E4C2E8214A1008342E8 /* StikDebug.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = StikDebug.storekit; sourceTree = "<group>"; };
DC139F6C2DE97EA400F63846 /* DebugWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DebugWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
DC139F6D2DE97EA400F63846 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
DC139F6F2DE97EA400F63846 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
@ -193,6 +195,7 @@
isa = PBXGroup;
children = (
DC139F862DE97F2000F63846 /* DebugWidgetExtension.entitlements */,
68AA2E4C2E8214A1008342E8 /* StikDebug.storekit */,
DC6F1D392D94EADD0071B2B6 /* StikJIT */,
DC6F1D4B2D94EADF0071B2B6 /* StikJITTests */,
DC6F1D552D94EADF0071B2B6 /* StikJITUITests */,
@ -416,6 +419,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
68AA2E4D2E8214A1008342E8 /* StikDebug.storekit in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View file

@ -74,6 +74,9 @@
ReferencedContainer = "container:StikDebug.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../../StikDebug.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"

View file

@ -16,42 +16,55 @@ struct ScriptEditorView: View {
@State private var position: CodeEditor.Position = .init()
@State private var messages: Set<TextLocated<Message>> = []
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
@Environment(\.themeExpansionManager) private var themeExpansion
private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) }
private var editorTheme: Theme {
colorScheme == .dark ? Theme.defaultDark : Theme.defaultLight
}
var body: some View {
VStack(spacing: 0) {
CodeEditor(
text: $scriptContent,
position: $position,
messages: $messages,
language: .swift()
)
.font(.system(.footnote, design: .monospaced))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.environment(
\.codeEditorTheme,
colorScheme == .dark ? Theme.defaultDark : Theme.defaultLight
)
ZStack {
ThemedBackground(style: backgroundStyle)
.ignoresSafeArea()
Divider()
VStack(spacing: 0) {
CodeEditor(
text: $scriptContent,
position: $position,
messages: $messages,
language: .swift()
)
.font(.system(.footnote, design: .monospaced))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.environment(\.codeEditorTheme, editorTheme)
HStack(spacing: 12) {
WideGlassyButton(title: "Cancel", systemImage: "xmark") {
dismiss()
}
WideGlassyButton(title: "Save", systemImage: "checkmark") {
saveScript()
dismiss()
Divider()
HStack(spacing: 12) {
WideGlassyButton(title: "Cancel", systemImage: "xmark") {
dismiss()
}
WideGlassyButton(title: "Save", systemImage: "checkmark") {
saveScript()
dismiss()
}
}
.padding(.horizontal)
.padding(.vertical, 12)
.frame(maxWidth: .infinity)
.background(bottomBarBackground)
}
.padding(.horizontal)
.padding(.vertical, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.navigationTitle(scriptURL.lastPathComponent)
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadScript)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.preferredColorScheme(preferredScheme)
}
private func loadScript() {
@ -61,6 +74,17 @@ struct ScriptEditorView: View {
private func saveScript() {
try? scriptContent.write(to: scriptURL, atomically: true, encoding: .utf8)
}
private var bottomBarBackground: some View {
Rectangle()
.fill(Color.black.opacity(colorScheme == .dark ? 0.35 : 0.08))
.overlay(
Rectangle()
.fill(Color.white.opacity(0.08))
.frame(height: 1),
alignment: .top
)
}
}
// MARK: - Equal-width rounded-rectangle button (centered content)

View file

@ -37,12 +37,14 @@ struct ScriptListView: View {
}
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
private var currentTheme: AppTheme { AppTheme(rawValue: appThemeRaw) ?? .system }
@Environment(\.themeExpansionManager) private var themeExpansion
private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) }
var body: some View {
NavigationStack {
ZStack {
ThemedBackground(style: currentTheme.backgroundStyle)
ThemedBackground(style: backgroundStyle)
.ignoresSafeArea()
ScrollView {
@ -134,6 +136,7 @@ struct ScriptListView: View {
}
}
}
.preferredColorScheme(preferredScheme)
}
// MARK: - Cards
@ -251,13 +254,41 @@ struct ScriptListView: View {
}
private var glassyBackground: some View {
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)
ZStack {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(.ultraThinMaterial)
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(
LinearGradient(
gradient: Gradient(colors: overlayColors()),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.opacity(0.32)
RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
}
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
}
private func overlayColors() -> [Color] {
let colors: [Color]
switch backgroundStyle {
case .staticGradient(let palette):
colors = palette
case .animatedGradient(let palette, _):
colors = palette
case .blobs(_, let background):
colors = background
case .particles(let particle, let background):
colors = background.isEmpty ? [particle, particle.opacity(0.4)] : background
case .customGradient(let palette):
colors = palette
}
if colors.count >= 2 { return colors }
if let first = colors.first { return [first, first.opacity(0.6)] }
return [Color.blue, Color.purple]
}
// MARK: - File Ops

View file

@ -25,9 +25,10 @@ struct WelcomeSheetView: View {
var onDismiss: (() -> Void)?
@Environment(\.colorScheme) private var colorScheme
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
@Environment(\.themeExpansionManager) private var themeExpansion
private var accent: Color {
customAccentColorHex.isEmpty ? .accentColor : (Color(hex: customAccentColorHex) ?? .accentColor)
themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue
}
var body: some View {
@ -496,11 +497,11 @@ struct HeartbeatApp: App {
@State private var alert_string = ""
@State private var alert_title = ""
@State private var showTimeoutError = false
@State private var showLogs = false
@State private var showContinueWarning = false
@State private var timeoutTimer: Timer?
@StateObject private var mount = MountingProgress.shared
@StateObject private var dnsChecker = DNSChecker()
@StateObject private var themeExpansionManager = ThemeExpansionManager()
@Environment(\.scenePhase) private var scenePhase // Observe scene lifecycle
let urls: [String] = [
@ -524,14 +525,18 @@ struct HeartbeatApp: App {
let origMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, #selector(UIDocumentPickerViewController.init(forOpeningContentTypes:asCopy:)))!
method_exchangeImplementations(origMethod, fixMethod)
// Initialize UIKit tint from stored accent at launch
HeartbeatApp.updateUIKitTintFromStoredAccent()
// Initialize UIKit tint from stored accent at launch (defaults to blue until entitlements load)
HeartbeatApp.updateUIKitTint(customHex: customAccentColorHex, hasAccess: false)
}
// Make this static so we can call it without capturing self in init
private static func updateUIKitTintFromStoredAccent() {
let hex = UserDefaults.standard.string(forKey: "customAccentColor") ?? ""
let color = hex.isEmpty ? UIColor.tintColor : UIColor(Color(hex: hex) ?? .accentColor)
private static func updateUIKitTint(customHex: String, hasAccess: Bool) {
let color: UIColor
if hasAccess, !customHex.isEmpty, let swiftColor = Color(hex: customHex) {
color = UIColor(swiftColor)
} else {
color = .systemBlue
}
UIView.appearance().tintColor = color
}
@ -554,8 +559,7 @@ struct HeartbeatApp: App {
}
private var globalAccent: Color {
let hex = customAccentColorHex
return hex.isEmpty ? .accentColor : (Color(hex: hex) ?? .accentColor)
themeExpansionManager.resolvedAccentColor(from: customAccentColorHex)
}
var body: some Scene {
@ -646,14 +650,9 @@ struct HeartbeatApp: App {
},
showButton: true,
primaryButtonText: "Continue Anyway",
secondaryButtonText: "View Logs",
onPrimaryButtonTap: {
showContinueWarning = true
},
onSecondaryButtonTap: {
showLogs = true
},
showSecondaryButton: true
}
)
}
@ -674,11 +673,6 @@ struct HeartbeatApp: App {
}
}
)
.sheet(isPresented: $showLogs, onDismiss: {
isLoading2 = false
}) {
ConsoleLogsView()
}
} else {
MainTabView()
.onAppear {
@ -713,33 +707,34 @@ struct HeartbeatApp: App {
)
}
}
// Apply global tint to all SwiftUI views in this window
.tint(globalAccent)
.onAppear {
// On first launch, present the welcome sheet.
// Otherwise, start the VPN automatically.
if !hasLaunchedBefore {
showWelcomeSheet = true
} else {
TunnelManager.shared.startVPN()
}
// Update UIKit tint now and subscribe to changes without capturing self
HeartbeatApp.updateUIKitTintFromStoredAccent()
NotificationCenter.default.addObserver(
forName: UserDefaults.didChangeNotification,
object: nil,
queue: .main
) { _ in
HeartbeatApp.updateUIKitTintFromStoredAccent()
}
}
.themeExpansionManager(themeExpansionManager)
// Apply global tint to all SwiftUI views in this window
.tint(globalAccent)
.onAppear {
// On first launch, present the welcome sheet.
// Otherwise, start the VPN automatically.
if !hasLaunchedBefore {
showWelcomeSheet = true
} else {
TunnelManager.shared.startVPN()
}
.sheet(isPresented: $showWelcomeSheet) {
WelcomeSheetView {
// When the user taps "Continue", mark the app as launched and start the VPN.
hasLaunchedBefore = true
showWelcomeSheet = false
TunnelManager.shared.startVPN()
}
HeartbeatApp.updateUIKitTint(customHex: customAccentColorHex,
hasAccess: themeExpansionManager.hasThemeExpansion)
}
.onChange(of: themeExpansionManager.hasThemeExpansion) { hasAccess in
HeartbeatApp.updateUIKitTint(customHex: customAccentColorHex, hasAccess: hasAccess)
}
.onChange(of: customAccentColorHex) { newHex in
HeartbeatApp.updateUIKitTint(customHex: newHex,
hasAccess: themeExpansionManager.hasThemeExpansion)
}
.sheet(isPresented: $showWelcomeSheet) {
WelcomeSheetView {
// When the user taps "Continue", mark the app as launched and start the VPN.
hasLaunchedBefore = true
showWelcomeSheet = false
TunnelManager.shared.startVPN()
}
}
}
@ -954,13 +949,10 @@ struct LoadingView: View {
@State private var animate = false
@Environment(\.colorScheme) private var colorScheme
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
@Environment(\.themeExpansionManager) private var themeExpansion
private var accentColor: Color {
if customAccentColorHex.isEmpty {
return .accentColor
} else {
return Color(hex: customAccentColorHex) ?? .accentColor
}
themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue
}
var body: some View {
@ -1109,4 +1101,3 @@ func downloadFile(from urlString: String, to destinationURL: URL, completion: @e
task.resume()
completion("")
}

View file

@ -10,63 +10,108 @@ import SwiftUI
// MARK: - AppTheme model
enum AppTheme: String, CaseIterable, Identifiable {
case system // follows system appearance; static gradient
case darkStatic // dark + static gradient
case neonAnimated // animated gradient
case blobs // morphing blobs
case particles // subtle particle field
case system // balanced default gradient
case darkStatic // deep charcoal blend
case neonAnimated // neon pulse
case blobs // vibrant haze
case particles // subtle celestial particles
case aurora // shifting aurora lights
case sunset // warm sunset glow
case ocean // tranquil ocean blues
case forest // lush forest canopy
case midnight // midnight horizon
case cyberwave // synthwave inspired
var id: String { rawValue }
var displayName: String {
switch self {
case .system: return "System"
case .darkStatic: return "Dark"
case .neonAnimated: return "Neon"
case .blobs: return "Blobs"
case .particles: return "Particles"
case .system: return "Default"
case .darkStatic: return "Obsidian"
case .neonAnimated: return "Neon Pulse"
case .blobs: return "Haze"
case .particles: return "Stardust"
case .aurora: return "Aurora"
case .sunset: return "Sunset"
case .ocean: return "Ocean"
case .forest: return "Forest"
case .midnight: return "Midnight"
case .cyberwave: return "Cyberwave"
}
}
// If you want the theme to force a color scheme, set it here.
// Your app currently forces dark globally; we keep nil so nothing fights it.
var preferredColorScheme: ColorScheme? {
switch self {
case .system: return nil
case .darkStatic: return .dark
case .neonAnimated: return .dark
case .blobs: return .dark
case .particles: return .dark
case .system, .sunset, .forest: return nil
case .darkStatic, .neonAnimated, .blobs, .particles, .aurora, .ocean, .midnight, .cyberwave: return .dark
}
}
var backgroundStyle: BackgroundStyle {
switch self {
case .system: return .staticGradient
case .darkStatic: return .staticGradient
case .neonAnimated: return .animatedGradient
case .blobs: return .blobs
case .particles: return .particles
case .system:
return .staticGradient(colors: Palette.defaultGradient)
case .darkStatic:
return .staticGradient(colors: Palette.obsidianGradient)
case .neonAnimated:
return .animatedGradient(colors: Palette.neon, speed: 0.10)
case .blobs:
return .blobs(colors: Palette.hazeBlobs, background: Palette.hazeBackground)
case .particles:
return .particles(particle: Palette.stardustParticle, background: Palette.stardustBackground)
case .aurora:
return .animatedGradient(colors: Palette.aurora, speed: 0.08)
case .sunset:
return .staticGradient(colors: Palette.sunset)
case .ocean:
return .animatedGradient(colors: Palette.ocean, speed: 0.06)
case .forest:
return .staticGradient(colors: Palette.forest)
case .midnight:
return .particles(particle: Palette.midnightParticle, background: Palette.midnightBackground)
case .cyberwave:
return .blobs(colors: Palette.cyberwaveBlobs, background: Palette.cyberwaveBackground)
}
}
}
// MARK: - Background styles and factory
enum BackgroundStyle {
case staticGradient
case animatedGradient
case blobs
case particles
enum BackgroundStyle: Equatable {
case staticGradient(colors: [Color])
case animatedGradient(colors: [Color], speed: Double)
case blobs(colors: [Color], background: [Color])
case particles(particle: Color, background: [Color])
case customGradient(colors: [Color])
}
// Slightly brighter, clearly-not-black static background that still respects dark mode
private func staticBackgroundGradient() -> LinearGradient {
private struct Palette {
static let defaultGradient = hexColors("#1C1F3A", "#3E4C7C", "#1F1C2C")
static let obsidianGradient = hexColors("#000000", "#1C1C1C", "#262626")
static let neon = hexColors("#00F5A0", "#00D9F5", "#C96BFF")
static let hazeBlobs = hexColors("#FF8BA7", "#A98BFF", "#70C8FF", "#67FFDA")
static let hazeBackground = hexColors("#141321", "#1E1C2A")
static let stardustParticle = Color(hex: "#9BD4FF") ?? .white
static let stardustBackground = hexColors("#090A1A", "#1C1F3A")
static let aurora = hexColors("#0BA360", "#3CBA92", "#8241FF")
static let sunset = hexColors("#FF5F6D", "#FFC371", "#FF9966")
static let ocean = hexColors("#0093E9", "#80D0C7", "#13547A")
static let forest = hexColors("#2F7336", "#AA3A38", "#052E03")
static let midnightParticle = Color(hex: "#9F9FFF") ?? .white
static let midnightBackground = hexColors("#0F2027", "#203A43", "#2C5364")
static let cyberwaveBlobs = hexColors("#FF0080", "#7928CA", "#2A2A72", "#00F0FF")
static let cyberwaveBackground = hexColors("#08001A", "#110032")
static func hexColors(_ hexes: String...) -> [Color] {
hexes.compactMap { Color(hex: $0) }
}
}
// MARK: - Helpers
private func staticGradientView(colors: [Color]) -> some View {
LinearGradient(
gradient: Gradient(colors: [
Color(UIColor.systemBackground),
Color(UIColor.secondarySystemBackground)
]),
gradient: Gradient(colors: colors.ensureMinimumCount()),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
@ -79,30 +124,33 @@ struct ThemedBackground: View {
var body: some View {
Group {
switch style {
case .staticGradient:
staticBackgroundGradient()
case .staticGradient(let colors):
staticGradientView(colors: colors)
.ignoresSafeArea()
case .animatedGradient:
case .animatedGradient(let colors, let speed):
if reduceMotion {
staticBackgroundGradient()
staticGradientView(colors: colors)
.ignoresSafeArea()
} else {
AnimatedGradientBackground()
AnimatedGradientBackground(colors: colors, speed: speed)
}
case .blobs:
case .blobs(let colors, let background):
if reduceMotion {
staticBackgroundGradient()
staticGradientView(colors: background.isEmpty ? colors : background)
.ignoresSafeArea()
} else {
BlobBackground()
BlobBackground(blobColors: colors.ensureMinimumCount(), backgroundColors: background.ensureMinimumCount())
}
case .particles:
case .particles(let particle, let background):
if reduceMotion {
staticBackgroundGradient()
staticGradientView(colors: background.ensureMinimumCount())
.ignoresSafeArea()
} else {
ParticleFieldBackground()
ParticleFieldBackground(particleColor: particle, backgroundColors: background.ensureMinimumCount())
}
case .customGradient(let colors):
staticGradientView(colors: colors)
.ignoresSafeArea()
}
}
}
@ -112,52 +160,54 @@ struct ThemedBackground: View {
struct BackgroundContainer<Content: View>: View {
@AppStorage("appTheme") private var rawTheme: String = AppTheme.system.rawValue
@Environment(\.themeExpansionManager) private var themeExpansion
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
private var theme: AppTheme {
AppTheme(rawValue: rawTheme) ?? .system
private var backgroundStyle: BackgroundStyle {
themeExpansion?.backgroundStyle(for: rawTheme) ?? AppTheme.system.backgroundStyle
}
private var preferredScheme: ColorScheme? {
themeExpansion?.preferredColorScheme(for: rawTheme)
}
var body: some View {
ZStack {
ThemedBackground(style: theme.backgroundStyle)
ThemedBackground(style: backgroundStyle)
.ignoresSafeArea()
content
}
.preferredColorScheme(theme.preferredColorScheme) // nil means no override
.preferredColorScheme(preferredScheme)
}
}
// MARK: - Animated backgrounds
private struct AnimatedGradientBackground: View {
@State private var t: Double = 0
let colors: [Color]
let speed: Double
var body: some View {
TimelineView(.animation) { timeline in
let now = timeline.date.timeIntervalSinceReferenceDate
let speed = 0.15
let phase = now * speed
let c1 = Color(hue: (sin(phase) * 0.5 + 0.5), saturation: 0.55, brightness: 0.45)
let c2 = Color(hue: (sin(phase + .pi * 0.66) * 0.5 + 0.5), saturation: 0.75, brightness: 0.35)
let c3 = Color(hue: (sin(phase + .pi * 1.33) * 0.5 + 0.5), saturation: 0.65, brightness: 0.30)
let angle = Angle(degrees: (sin(phase * 0.7) * 45) + 45)
AngularGradient(colors: [c1, c2, c3, c1], center: .center, angle: angle)
.saturation(1.0)
.brightness(0.0)
.opacity(0.9)
let rotation = Angle(degrees: phase.truncatingRemainder(dividingBy: 360) * 45)
let gradientColors = colors.ensureMinimumCount()
Rectangle()
.fill(
AngularGradient(colors: gradientColors + [gradientColors.first!], center: .center, angle: .degrees(0))
)
.hueRotation(.degrees(phase.truncatingRemainder(dividingBy: 360) * 30))
.rotationEffect(rotation)
.scaleEffect(1.2)
.ignoresSafeArea()
.overlay(
LinearGradient(colors: [.black.opacity(0.25), .clear],
startPoint: .top,
endPoint: .bottom)
LinearGradient(colors: [.black.opacity(0.25), .clear], startPoint: .top, endPoint: .bottom)
.ignoresSafeArea()
)
}
@ -165,39 +215,41 @@ private struct AnimatedGradientBackground: View {
}
private struct BlobBackground: View {
let blobColors: [Color]
let backgroundColors: [Color]
@State private var t: CGFloat = 0
var body: some View {
Canvas { ctx, size in
let baseColors: [Color] = [
.blue.opacity(0.35),
.purple.opacity(0.35),
.pink.opacity(0.35),
.cyan.opacity(0.35)
]
let blobs = 6
let blobs = max(blobColors.count, 4)
let radius = min(size.width, size.height) * 0.45
for i in 0..<blobs {
let color = blobColors[i % blobColors.count].opacity(0.32)
let p = CGFloat(i) / CGFloat(blobs)
let angle = t * (0.5 + 0.1 * CGFloat(i)) + p * .pi * 2
let r = radius * (0.4 + 0.2 * sin(t + CGFloat(i)))
let angle = t * (0.45 + 0.08 * CGFloat(i)) + p * .pi * 2
let r = radius * (0.38 + 0.18 * sin(t + CGFloat(i)))
let x = size.width * 0.5 + cos(angle) * r
let y = size.height * 0.5 + sin(angle * 0.9) * r * 0.7
let y = size.height * 0.5 + sin(angle * 0.92) * r * 0.72
var path = Path()
path.addEllipse(in: CGRect(x: x - 140, y: y - 140, width: 280, height: 280))
ctx.addFilter(.blur(radius: 40))
ctx.fill(path, with: .color(baseColors[i % baseColors.count]))
ctx.addFilter(.blur(radius: 42))
ctx.fill(path, with: .color(color))
}
}
.background(staticBackgroundGradient())
.background(
LinearGradient(
gradient: Gradient(colors: backgroundColors.ensureMinimumCount()),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.ignoresSafeArea()
.onAppear {
withAnimation(.linear(duration: 12).repeatForever(autoreverses: false)) {
withAnimation(.linear(duration: 14).repeatForever(autoreverses: false)) {
t = .pi * 2
}
}
@ -213,6 +265,8 @@ private struct ParticleFieldBackground: View {
var opacity: Double
}
let particleColor: Color
let backgroundColors: [Color]
@State private var particles: [Particle] = []
private let count = 120
@ -220,8 +274,12 @@ private struct ParticleFieldBackground: View {
GeometryReader { geo in
TimelineView(.animation) { _ in
ZStack {
staticBackgroundGradient()
.ignoresSafeArea()
LinearGradient(
gradient: Gradient(colors: backgroundColors.ensureMinimumCount()),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
Canvas { ctx, size in
for p in particles {
@ -229,9 +287,9 @@ private struct ParticleFieldBackground: View {
y: p.position.y,
width: p.size,
height: p.size))
ctx.addFilter(.blur(radius: 1.2))
ctx.addFilter(.blur(radius: 1.1))
ctx.opacity = p.opacity
ctx.fill(circle, with: .color(.white.opacity(0.85)))
ctx.fill(circle, with: .color(particleColor.opacity(0.9)))
}
}
}
@ -241,10 +299,10 @@ private struct ParticleFieldBackground: View {
Particle(
position: CGPoint(x: .random(in: 0...geo.size.width),
y: .random(in: 0...geo.size.height)),
velocity: CGVector(dx: .random(in: -0.3...0.3),
dy: .random(in: -0.3...0.3)),
size: .random(in: 1.5...3.5),
opacity: .random(in: 0.15...0.45)
velocity: CGVector(dx: .random(in: -0.28...0.28),
dy: .random(in: -0.28...0.28)),
size: .random(in: 1.6...3.8),
opacity: .random(in: 0.20...0.45)
)
}
}
@ -281,3 +339,11 @@ private struct ParticleFieldBackground: View {
.ignoresSafeArea()
}
}
private extension Array where Element == Color {
func ensureMinimumCount() -> [Color] {
if isEmpty { return [.blue, .purple] }
if count == 1 { return [self[0], self[0].opacity(0.6)] }
return self
}
}

View file

@ -0,0 +1,322 @@
import Foundation
import StoreKit
import SwiftUI
enum CustomThemeStyle: String, Codable, CaseIterable, Identifiable {
case staticGradient
case animatedGradient
case blobs
case particles
var id: String { rawValue }
var displayName: String {
switch self {
case .staticGradient: return "Static Gradient"
case .animatedGradient: return "Animated Gradient"
case .blobs: return "Floating Blobs"
case .particles: return "Particle Field"
}
}
}
struct CustomTheme: Identifiable, Codable, Equatable {
var id: UUID
var name: String
var style: CustomThemeStyle
var colorHexes: [String]
var preferredColorSchemeRaw: String?
init(id: UUID = UUID(),
name: String,
style: CustomThemeStyle,
colorHexes: [String],
preferredColorScheme: ColorScheme?) {
self.id = id
self.name = name
self.style = style
self.colorHexes = colorHexes
self.preferredColorSchemeRaw = preferredColorScheme.map { $0 == .dark ? "dark" : "light" }
}
var preferredColorScheme: ColorScheme? {
switch preferredColorSchemeRaw {
case "light": return .light
case "dark": return .dark
default: return nil
}
}
var gradientColors: [Color] {
colorHexes.compactMap { Color(hex: $0) }
}
func updating(name: String? = nil,
style: CustomThemeStyle? = nil,
colors: [String]? = nil,
preferredColorScheme: ColorScheme?? = nil) -> CustomTheme {
CustomTheme(id: id,
name: name ?? self.name,
style: style ?? self.style,
colorHexes: colors ?? self.colorHexes,
preferredColorScheme: preferredColorScheme ?? self.preferredColorScheme)
}
}
@MainActor
final class ThemeExpansionManager: ObservableObject {
static let productIdentifier = "SD_Theme_Expansion"
@Published private(set) var hasThemeExpansion = false
@Published private(set) var themeExpansionProduct: Product?
@Published private(set) var isProcessing = false
@Published var lastError: String?
@Published private(set) var customThemes: [CustomTheme] = []
private var updatesTask: Task<Void, Never>?
private let isPreviewInstance: Bool
private let customThemesKey = "ThemeExpansion.CustomThemes"
init(previewUnlocked: Bool = false) {
self.isPreviewInstance = previewUnlocked
self.hasThemeExpansion = previewUnlocked
loadCustomThemes()
if previewUnlocked && customThemes.isEmpty {
customThemes = [
CustomTheme(name: "Vapor Trail",
style: .animatedGradient,
colorHexes: ["#FF00E0", "#00D0FF", "#7A00FF"],
preferredColorScheme: .dark)
]
}
guard !previewUnlocked else { return }
updatesTask = Task.detached(priority: .background) { [weak self] in
for await result in StoreKit.Transaction.updates {
await self?.handle(transactionResult: result)
}
}
Task { await refreshEntitlements() }
}
deinit {
updatesTask?.cancel()
}
func refreshEntitlements() async {
guard !isPreviewInstance else { return }
isProcessing = true
defer { isProcessing = false }
do {
lastError = nil
let products = try await Product.products(for: [Self.productIdentifier])
themeExpansionProduct = products.first
if let latest = await StoreKit.Transaction.latest(for: Self.productIdentifier) {
await handle(transactionResult: latest, finishTransaction: false)
} else {
hasThemeExpansion = false
}
} catch {
lastError = error.localizedDescription
}
}
func purchaseThemeExpansion() async {
guard !isPreviewInstance else { return }
let product: Product
if let cached = themeExpansionProduct {
product = cached
} else {
do {
let products = try await Product.products(for: [Self.productIdentifier])
if let first = products.first {
themeExpansionProduct = first
product = first
} else {
lastError = "Theme Expansion isn't available yet. Check your App Store configuration and try again."
return
}
} catch {
lastError = error.localizedDescription
return
}
}
isProcessing = true
defer { isProcessing = false }
do {
lastError = nil
let result = try await product.purchase()
switch result {
case .success(let verification):
await handle(transactionResult: verification)
case .pending:
lastError = "Purchase pending. You'll be notified when it's complete."
case .userCancelled:
break
@unknown default:
lastError = "Purchase failed due to an unknown error."
}
} catch {
lastError = error.localizedDescription
}
}
func resolvedAccentColor(from hex: String) -> Color {
guard hasThemeExpansion, !hex.isEmpty, let custom = Color(hex: hex) else { return .blue }
return custom
}
func resolvedTheme(from rawValue: String) -> AppTheme {
guard let theme = AppTheme(rawValue: rawValue), hasThemeExpansion || theme == .system else {
return .system
}
return theme
}
func backgroundStyle(for identifier: String) -> BackgroundStyle {
if hasThemeExpansion, let custom = customTheme(for: identifier) {
let colors = normalizedColors(from: custom.colorHexes)
switch custom.style {
case .staticGradient:
return .staticGradient(colors: colors)
case .animatedGradient:
return .animatedGradient(colors: colors, speed: 0.08)
case .blobs:
return .blobs(colors: colors, background: colors)
case .particles:
return .particles(particle: colors.first ?? .white, background: colors)
}
}
if let theme = AppTheme(rawValue: identifier), (hasThemeExpansion || theme == .system) {
return theme.backgroundStyle
}
return AppTheme.system.backgroundStyle
}
func preferredColorScheme(for identifier: String) -> ColorScheme? {
if hasThemeExpansion, let custom = customTheme(for: identifier) {
return custom.preferredColorScheme
}
if let theme = AppTheme(rawValue: identifier), (hasThemeExpansion || theme == .system) {
return theme.preferredColorScheme
}
return nil
}
func customThemeIdentifier(for theme: CustomTheme) -> String {
"custom:\(theme.id.uuidString)"
}
func isCustomThemeIdentifier(_ identifier: String) -> Bool {
identifier.hasPrefix("custom:")
}
func customTheme(for identifier: String) -> CustomTheme? {
guard isCustomThemeIdentifier(identifier) else { return nil }
let idString = identifier.replacingOccurrences(of: "custom:", with: "")
guard let uuid = UUID(uuidString: idString) else { return nil }
return customThemes.first { $0.id == uuid }
}
func upsert(customTheme: CustomTheme) {
guard hasThemeExpansion || isPreviewInstance else { return }
var sanitized = customTheme
sanitized.colorHexes = sanitize(hexes: sanitized.colorHexes)
if let index = customThemes.firstIndex(where: { $0.id == sanitized.id }) {
customThemes[index] = sanitized
} else {
customThemes.append(sanitized)
}
saveCustomThemes()
}
func delete(customTheme: CustomTheme) {
customThemes.removeAll { $0.id == customTheme.id }
saveCustomThemes()
}
private func loadCustomThemes() {
guard let data = UserDefaults.standard.data(forKey: customThemesKey),
let decoded = try? JSONDecoder().decode([CustomTheme].self, from: data) else {
return
}
customThemes = decoded
}
private func saveCustomThemes() {
guard let data = try? JSONEncoder().encode(customThemes) else { return }
UserDefaults.standard.set(data, forKey: customThemesKey)
}
private func sanitize(hexes: [String]) -> [String] {
let cleaned = hexes.filter { !$0.isEmpty }
if cleaned.count >= 2 { return cleaned }
if let first = cleaned.first {
return [first, first]
}
return ["#1C1F3A", "#3E4C7C"]
}
private func normalizedColors(from hexes: [String]) -> [Color] {
let colors = hexes.compactMap { Color(hex: $0) }
if colors.count >= 2 { return colors }
if let first = colors.first { return [first, first.opacity(0.65)] }
return [Color.blue, Color.purple]
}
private func handle(transactionResult: VerificationResult<StoreKit.Transaction>, finishTransaction: Bool = true) async {
switch transactionResult {
case .verified(let transaction):
if transaction.productID == Self.productIdentifier {
hasThemeExpansion = true
lastError = nil
}
if finishTransaction {
await transaction.finish()
}
case .unverified(_, let error):
lastError = error.localizedDescription
}
}
}
#if DEBUG
extension ThemeExpansionManager {
static func previewUnlocked() -> ThemeExpansionManager {
ThemeExpansionManager(previewUnlocked: true)
}
static func previewLocked() -> ThemeExpansionManager {
ThemeExpansionManager(previewUnlocked: false)
}
}
#endif
// MARK: - Environment support
private struct ThemeExpansionEnvironmentKey: EnvironmentKey {
static let defaultValue: ThemeExpansionManager? = nil
}
extension EnvironmentValues {
var themeExpansionManager: ThemeExpansionManager? {
get { self[ThemeExpansionEnvironmentKey.self] }
set { self[ThemeExpansionEnvironmentKey.self] = newValue }
}
}
extension View {
func themeExpansionManager(_ manager: ThemeExpansionManager) -> some View {
environment(\.themeExpansionManager, manager)
}
}

View file

@ -13,10 +13,7 @@ struct ConsoleLogsView: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.accentColor) private var environmentAccentColor
@StateObject private var logManager = LogManager.shared
@StateObject private var systemLogStream = SystemLogStream()
@State private var selectedTab: ConsoleLogsTab = .jit
@State private var jitScrollView: ScrollViewProxy? = nil
@State private var systemScrollView: ScrollViewProxy? = nil
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
@State private var showingCustomAlert = false
@ -30,103 +27,32 @@ struct ConsoleLogsView: View {
@State private var lastProcessedLineCount = 0
@State private var isLoadingLogs = false
@State private var jitIsAtBottom = true
@State private var systemIsAtBottom = true
@State private var systemFilterText: String = ""
@State private var systemFilterLevel: SystemLogFilterLevel = .all
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
@Environment(\.themeExpansionManager) private var themeExpansion
private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) }
private var accentColor: Color {
if customAccentColorHex.isEmpty {
return .blue
} else {
return Color(hex: customAccentColorHex) ?? .blue
}
themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue
}
private let systemSpeedOptions: [(title: String, interval: TimeInterval)] = [
("Real-time", 0),
("0.25s", 0.25),
("0.5s", 0.5),
("1s", 1),
("2s", 2)
]
private enum ConsoleLogsTab: Int, CaseIterable, Identifiable {
case jit
case system
var id: Int { rawValue }
var title: String {
switch self {
case .jit: return "App Logs"
case .system: return "System Logs"
}
}
private var overlayOpacity: Double {
colorScheme == .dark ? 0.82 : 0.9
}
private enum SystemLogFilterLevel: String, CaseIterable, Identifiable {
case all
case warnings
case errors
var id: String { rawValue }
var title: String {
switch self {
case .all: return "All"
case .warnings: return "Warnings"
case .errors: return "Errors"
}
}
func matches(_ line: String) -> Bool {
let lower = line.lowercased()
switch self {
case .all:
return true
case .warnings:
return lower.contains("warn") || lower.contains("warning")
case .errors:
return lower.contains("err") || lower.contains("fail") || lower.contains("fault") || lower.contains("critical")
}
}
}
var body: some View {
NavigationView {
ZStack {
ThemedBackground(style: backgroundStyle)
.ignoresSafeArea()
Color(colorScheme == .dark ? .black : .white)
.edgesIgnoringSafeArea(.all)
.opacity(overlayOpacity)
.ignoresSafeArea()
VStack(spacing: 0) {
tabPicker
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 8)
Divider()
.background(Color.white.opacity(colorScheme == .dark ? 0.05 : 0.15))
if selectedTab == .jit {
jitLogsPane
} else {
systemLogsPane
}
jitLogsPane
Spacer(minLength: 0)
if selectedTab == .jit {
jitFooter
} else {
systemFooter
}
}
.onChange(of: selectedTab) { tab in
if tab == .system {
systemLogStream.start()
} else {
systemLogStream.stop()
}
jitFooter
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@ -135,7 +61,7 @@ struct ConsoleLogsView: View {
.font(.headline)
.foregroundColor(colorScheme == .dark ? .white : .black)
}
ToolbarItem(placement: .navigationBarLeading) {
Button(action: { dismiss() }) {
HStack(spacing: 2) {
@ -145,7 +71,7 @@ struct ConsoleLogsView: View {
.foregroundColor(accentColor)
}
}
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
Button(action: {
@ -154,7 +80,7 @@ struct ConsoleLogsView: View {
Image(systemName: "arrow.clockwise")
.foregroundColor(accentColor)
}
Button(action: {
logManager.clearLogs()
}) {
@ -185,26 +111,10 @@ struct ConsoleLogsView: View {
}
}
)
.onDisappear {
systemLogStream.stop()
}
.onAppear {
if selectedTab == .system {
systemLogStream.start()
}
}
}
.preferredColorScheme(preferredScheme)
}
private var tabPicker: some View {
Picker("Log Source".localized, selection: $selectedTab) {
ForEach(ConsoleLogsTab.allCases) { tab in
Text(tab.title.localized).tag(tab)
}
}
.pickerStyle(.segmented)
}
private var jitLogsPane: some View {
ScrollViewReader { proxy in
ScrollView {
@ -277,133 +187,6 @@ struct ConsoleLogsView: View {
stopLogCheckTimer()
}
}
.onDisappear {
systemLogStream.stop()
}
}
private var systemLogsPane: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
systemFilterControls
if let error = systemLogStream.lastError {
Text(error)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.red)
.padding(.vertical, 4)
.padding(.horizontal, 4)
}
ForEach(filteredSystemLogs) { entry in
Text(AttributedString(systemLogAttributedString(entry)))
.font(.system(size: 11, design: .monospaced))
.textSelection(.enabled)
.lineLimit(nil)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 1)
.padding(.horizontal, 4)
.id(entry.id)
}
}
.background(
GeometryReader { geometry in
Color.clear.preference(
key: ScrollOffsetPreferenceKey.self,
value: geometry.frame(in: .named("systemScroll")).minY
)
}
)
}
.coordinateSpace(name: "systemScroll")
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in
systemIsAtBottom = offset > -20
}
.onChange(of: systemLogStream.entries.count) { _ in
guard systemIsAtBottom, let last = systemLogStream.entries.last else { return }
withAnimation {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
.onAppear {
systemScrollView = proxy
systemLogStream.start()
}
.onDisappear {
systemScrollView = nil
}
}
}
private var filteredSystemLogs: [SystemLogStream.Entry] {
var entries = systemLogStream.entries
let trimmed = systemFilterText.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
let query = trimmed.lowercased()
entries = entries.filter { entry in
entry.raw.lowercased().contains(query)
}
}
if systemFilterLevel != .all {
entries = entries.filter { systemFilterLevel.matches($0.raw) }
}
return entries
}
private var systemSpeedSymbol: String {
switch systemLogStream.updateInterval {
case 0:
return "hare.fill"
case ..<0.5:
return "tortoise.fill"
case ..<1.5:
return "tortoise"
default:
return "tortoise"
}
}
private var systemFilterControls: some View {
VStack(spacing: 10) {
HStack(spacing: 8) {
Image(systemName: "line.3.horizontal.decrease.circle")
.foregroundStyle(.secondary)
TextField("Filter".localized, text: $systemFilterText)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.font(.system(size: 12, design: .monospaced))
if !systemFilterText.isEmpty {
Button {
systemFilterText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("Clear filter".localized)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(.ultraThinMaterial)
)
Picker("Severity".localized, selection: $systemFilterLevel) {
ForEach(SystemLogFilterLevel.allCases) { level in
Text(level.title.localized).tag(level)
}
}
.pickerStyle(.segmented)
}
.padding(.horizontal, 4)
.padding(.bottom, 6)
}
private var jitFooter: some View {
@ -449,81 +232,6 @@ struct ConsoleLogsView: View {
.padding(.vertical, 10)
}
private var systemFooter: some View {
HStack(spacing: 12) {
if let error = systemLogStream.lastError {
Label(error, systemImage: "exclamationmark.triangle.fill")
.font(.subheadline)
.foregroundColor(.red)
.lineLimit(2)
} else {
let status = systemStatusDisplay()
Label(status.text, systemImage: status.icon)
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer(minLength: 8)
Button {
UIPasteboard.general.string = systemLogStream.concatenatedLog()
alertTitle = "System Logs Copied"
alertMessage = "Recent system logs copied to clipboard.".localized
isError = false
showingCustomAlert = true
} label: {
Image(systemName: "doc.on.doc")
.foregroundColor(accentColor)
}
.buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18))
.accessibilityLabel("Copy system logs")
Button {
systemLogStream.clear()
} label: {
Image(systemName: "trash")
.foregroundColor(accentColor)
}
.buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18))
.accessibilityLabel("Clear system logs")
Button {
systemLogStream.togglePause()
} label: {
Image(systemName: systemLogStream.isPaused ? "play.fill" : "pause.fill")
.foregroundColor(accentColor)
}
.buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18))
.accessibilityLabel(systemLogStream.isPaused ? "Resume stream" : "Pause stream")
Menu {
ForEach(systemSpeedOptions, id: \.title) { option in
Button(option.title.localized) {
systemLogStream.updateInterval = option.interval
}
}
} label: {
Image(systemName: systemSpeedSymbol)
.foregroundColor(accentColor)
}
.buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18))
.accessibilityLabel("Adjust speed")
if !systemLogStream.isStreaming {
Button {
systemLogStream.start()
} label: {
Image(systemName: "arrow.clockwise")
.foregroundColor(accentColor)
}
.buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18))
.accessibilityLabel("Restart stream")
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
@ViewBuilder
private var exportControl: some View {
let logURL: URL = URL.documentsDirectory.appendingPathComponent("idevice_log.txt")
@ -580,30 +288,6 @@ struct ConsoleLogsView: View {
return fullString
}
private func systemLogAttributedString(_ entry: SystemLogStream.Entry) -> NSAttributedString {
let fullString = NSMutableAttributedString()
let timestamp = "[\(formatTime(date: entry.timestamp))]"
let tsAttr = NSAttributedString(string: timestamp,
attributes: [.foregroundColor: colorScheme == .dark ? UIColor.gray : UIColor.darkGray])
fullString.append(tsAttr)
fullString.append(NSAttributedString(string: " "))
let type = systemLogType(for: entry.raw)
let typeString = "[\(type.rawValue)]"
let typeAttr = NSAttributedString(string: typeString,
attributes: [.foregroundColor: UIColor(colorForLogType(type))])
fullString.append(typeAttr)
fullString.append(NSAttributedString(string: " "))
let messageAttr = NSAttributedString(string: entry.message,
attributes: [.foregroundColor: colorScheme == .dark ? UIColor.white : UIColor.black])
fullString.append(messageAttr)
return fullString
}
private func formatTime(date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
@ -622,33 +306,6 @@ struct ConsoleLogsView: View {
return .orange
}
}
private func systemLogType(for raw: String) -> LogManager.LogEntry.LogType {
let lower = raw.lowercased()
if lower.contains("error") || lower.contains("failed") || lower.contains("fatal") || lower.contains("fault") {
return .error
}
if lower.contains("warn") {
return .warning
}
if lower.contains("debug") || lower.contains("trace") {
return .debug
}
return .info
}
private func systemStatusDisplay() -> (text: String, icon: String) {
if !systemLogStream.isStreaming {
return ("Stopped".localized, "pause.circle")
}
if systemLogStream.isPaused {
return ("Paused".localized, "pause.circle")
}
if systemLogStream.updateInterval > 0 {
return ("Throttled".localized, systemSpeedSymbol)
}
return ("Streaming".localized, "dot.radiowaves.left.and.right")
}
private func loadIdeviceLogsAsync() async {
guard !isLoadingLogs else { return }
@ -804,6 +461,7 @@ private struct GlassOvalButtonStyle: ButtonStyle {
struct ConsoleLogsView_Previews: PreviewProvider {
static var previews: some View {
ConsoleLogsView()
.themeExpansionManager(ThemeExpansionManager(previewUnlocked: true))
}
}

View file

@ -31,11 +31,8 @@ struct CustomErrorView: View {
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
private var accentColor: Color {
if customAccentColorHex.isEmpty {
return .white
} else {
return Color(hex: customAccentColorHex) ?? .white
}
if customAccentColorHex.isEmpty { return .blue }
return Color(hex: customAccentColorHex) ?? .blue
}

View file

@ -147,12 +147,14 @@ struct DeviceInfoView: View {
}
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
private var currentTheme: AppTheme { AppTheme(rawValue: appThemeRaw) ?? .system }
@Environment(\.themeExpansionManager) private var themeExpansion
private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) }
var body: some View {
NavigationStack {
ZStack {
ThemedBackground(style: currentTheme.backgroundStyle)
ThemedBackground(style: backgroundStyle)
.ignoresSafeArea()
ScrollView {
@ -260,6 +262,7 @@ struct DeviceInfoView: View {
.onAppear { if isPaired { mgr.initAndLoad() } }
.onDisappear { mgr.cleanup() }
}
.preferredColorScheme(preferredScheme)
}
// MARK: - UI Sections

View file

@ -74,44 +74,56 @@ struct DisplayView: View {
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
@AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true
@State private var selectedAccentColor: Color = .white
@State private var selectedAccentColor: Color = .blue
@Environment(\.colorScheme) private var colorScheme
@Environment(\.themeExpansionManager) private var themeExpansionOptional
@State private var justSaved = false
@State private var showingCreateCustomTheme = false
@State private var editingCustomTheme: CustomTheme?
private var themeExpansion: ThemeExpansionManager? { themeExpansionOptional }
private var hasThemeExpansion: Bool { themeExpansion?.hasThemeExpansion == true }
private var accentColor: Color {
if customAccentColorHex.isEmpty {
return .white
} else {
return Color(hex: customAccentColorHex) ?? .white
}
themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue
}
private var selectedTheme: AppTheme {
get { AppTheme(rawValue: appThemeRaw) ?? .system }
set { appThemeRaw = newValue.rawValue }
private var tintColor: Color {
hasThemeExpansion ? selectedAccentColor : .blue
}
private var hardcodedGradient: some View {
LinearGradient(
gradient: Gradient(colors: [
Color(UIColor.systemBackground),
Color(UIColor.secondarySystemBackground)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
private var selectedThemeIdentifier: String { appThemeRaw }
private var selectedBuiltInTheme: AppTheme? {
AppTheme(rawValue: selectedThemeIdentifier)
}
private var selectedCustomTheme: CustomTheme? {
themeExpansion?.customTheme(for: selectedThemeIdentifier)
}
private var backgroundStyle: BackgroundStyle {
themeExpansion?.backgroundStyle(for: selectedThemeIdentifier) ?? AppTheme.system.backgroundStyle
}
var body: some View {
NavigationStack {
ZStack {
hardcodedGradient.ignoresSafeArea()
ThemedBackground(style: backgroundStyle)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 20) {
usernameCard
accentCard
if hasThemeExpansion {
accentCard
themeCard
customThemesSection
} else {
themeExpansionUpsellCard
}
jitOptionsCard
}
.padding(.horizontal, 20)
@ -136,11 +148,61 @@ struct DisplayView: View {
}
.navigationTitle("Display")
.onAppear {
if !hasThemeExpansion, let manager = themeExpansion, manager.isCustomThemeIdentifier(appThemeRaw) {
appThemeRaw = AppTheme.system.rawValue
}
loadCustomAccentColor()
applyTheme(selectedTheme)
applyThemePreferences()
}
.onChange(of: appThemeRaw) { _, newValue in
guard hasThemeExpansion, let manager = themeExpansion else { return }
if manager.isCustomThemeIdentifier(newValue), manager.customTheme(for: newValue) == nil {
appThemeRaw = AppTheme.system.rawValue
}
applyThemePreferences()
}
.onChange(of: themeExpansion?.hasThemeExpansion ?? false) { unlocked in
if unlocked {
loadCustomAccentColor()
applyThemePreferences()
} else {
selectedAccentColor = .blue
appThemeRaw = AppTheme.system.rawValue
applyThemePreferences()
}
}
}
.tint(selectedAccentColor)
.tint(tintColor)
.sheet(isPresented: $showingCreateCustomTheme) {
CustomThemeEditorView(initialTheme: nil) { newTheme in
themeExpansion?.upsert(customTheme: newTheme)
if let manager = themeExpansion {
appThemeRaw = manager.customThemeIdentifier(for: newTheme)
}
applyThemePreferences()
showSavedToast()
}
}
.sheet(item: $editingCustomTheme) { theme in
CustomThemeEditorView(initialTheme: theme,
onSave: { updated in
themeExpansion?.upsert(customTheme: updated)
if let manager = themeExpansion {
appThemeRaw = manager.customThemeIdentifier(for: updated)
}
applyThemePreferences()
showSavedToast()
},
onDelete: {
if let manager = themeExpansion {
manager.delete(customTheme: theme)
if manager.customThemeIdentifier(for: theme) == appThemeRaw {
appThemeRaw = AppTheme.system.rawValue
applyThemePreferences()
}
}
})
}
}
// MARK: - Cards
@ -194,9 +256,9 @@ struct DisplayView: View {
.font(.title3)
.fontWeight(.semibold)
.foregroundColor(.primary)
AccentColorPicker(selectedColor: $selectedAccentColor)
HStack(spacing: 12) {
Button {
if let hex = selectedAccentColor.toHex() {
@ -219,10 +281,10 @@ struct DisplayView: View {
)
.foregroundColor(selectedAccentColor.contrastText())
}
Button {
customAccentColorHex = ""
selectedAccentColor = .white
selectedAccentColor = .blue
showSavedToast()
} label: {
HStack {
@ -248,7 +310,115 @@ struct DisplayView: View {
)
)
.shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4)
.onChange(of: selectedAccentColor) { _, _ in
}
private var themeExpansionUpsellCard: some View {
let productLoaded = themeExpansion?.themeExpansionProduct != nil
return VStack(alignment: .leading, spacing: 14) {
Text("StikDebug Theme Expansion")
.font(.title3)
.fontWeight(.semibold)
.foregroundColor(.primary)
Text("Unlock custom accent colors and dynamic backgrounds with the Theme Expansion.")
.font(.body)
.foregroundColor(.secondary)
if let price = themeExpansion?.themeExpansionProduct?.displayPrice {
Text("One-time purchase • \(price)")
.font(.subheadline)
.foregroundColor(.secondary)
}
if productLoaded, let manager = themeExpansion {
Button {
Task { await manager.purchaseThemeExpansion() }
} label: {
HStack {
if manager.isProcessing {
ProgressView()
.progressViewStyle(.circular)
}
Text(manager.isProcessing ? "Purchasing…" : "Unlock Theme Expansion")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.blue)
)
.foregroundColor(Color.blue.contrastText())
}
.disabled(manager.isProcessing)
} else if let manager = themeExpansion {
Button {
Task { await manager.refreshEntitlements() }
} label: {
HStack {
if manager.isProcessing {
ProgressView()
.progressViewStyle(.circular)
}
Text(manager.isProcessing ? "Contacting App Store…" : "Try Again")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.blue.opacity(0.4), lineWidth: 1)
)
}
.disabled(manager.isProcessing)
}
if let manager = themeExpansion {
Button {
Task { await manager.refreshEntitlements() }
} label: {
Text("Restore Purchase")
.font(.subheadline)
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.blue.opacity(0.4), lineWidth: 1)
)
}
.disabled(manager.isProcessing)
}
if let manager = themeExpansion, !productLoaded, manager.lastError == nil {
Text(manager.isProcessing ? "Contacting the App Store…" : "Waiting for App Store information.")
.font(.footnote)
.foregroundColor(.secondary)
}
if let error = themeExpansion?.lastError {
Text(error)
.font(.footnote)
.foregroundColor(.red)
}
}
.padding(20)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
)
)
.shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4)
.task {
if let manager = themeExpansion,
!manager.isProcessing,
manager.themeExpansionProduct == nil,
manager.lastError == nil {
await manager.refreshEntitlements()
}
}
}
@ -290,10 +460,12 @@ struct DisplayView: View {
// Grid of theme previews
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
ForEach(AppTheme.allCases, id: \.self) { theme in
ThemePreviewCard(theme: theme, selected: selectedTheme == theme) {
// Write directly to AppStorage to avoid mutating through a computed property
ThemePreviewCard(style: theme.backgroundStyle,
title: theme.displayName,
selected: selectedBuiltInTheme == theme && selectedCustomTheme == nil) {
guard hasThemeExpansion else { return }
appThemeRaw = theme.rawValue
applyTheme(theme)
applyThemePreferences()
showSavedToast()
}
}
@ -308,28 +480,101 @@ struct DisplayView: View {
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
)
)
.shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4)
.shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4)
}
@ViewBuilder
private var customThemesSection: some View {
if hasThemeExpansion, let manager = themeExpansion {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text("Custom Themes")
.font(.title3)
.fontWeight(.semibold)
.foregroundColor(.primary)
Spacer()
Button {
showingCreateCustomTheme = true
} label: {
Label("New", systemImage: "plus.circle.fill")
.font(.subheadline.weight(.semibold))
}
}
if manager.customThemes.isEmpty {
VStack(spacing: 8) {
Text("Create your own themes with custom colors and motion.")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.leading)
Button(action: { showingCreateCustomTheme = true }) {
Text("Create a Custom Theme")
.font(.subheadline.weight(.semibold))
.padding(.vertical, 10)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.blue)
)
.foregroundColor(Color.blue.contrastText())
}
}
} else {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
ForEach(manager.customThemes, id: \.id) { theme in
let identifier = manager.customThemeIdentifier(for: theme)
ThemePreviewCard(style: manager.backgroundStyle(for: identifier),
title: theme.name,
selected: selectedCustomTheme?.id == theme.id) {
appThemeRaw = identifier
applyThemePreferences()
showSavedToast()
}
.contextMenu {
Button("Edit") { editingCustomTheme = theme }
Button("Delete", role: .destructive) {
manager.delete(customTheme: theme)
let id = manager.customThemeIdentifier(for: theme)
if appThemeRaw == id {
appThemeRaw = AppTheme.system.rawValue
applyThemePreferences()
}
}
}
}
}
}
}
.padding(20)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
)
)
.shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4)
}
}
// MARK: - Helpers
private func loadCustomAccentColor() {
if customAccentColorHex.isEmpty {
selectedAccentColor = .white
} else {
selectedAccentColor = Color(hex: customAccentColorHex) ?? .white
}
selectedAccentColor = themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue
}
private func applyTheme(_ theme: AppTheme) {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first {
switch theme {
case .darkStatic, .neonAnimated, .blobs, .particles:
window.overrideUserInterfaceStyle = .dark
case .system:
window.overrideUserInterfaceStyle = .unspecified
}
private func applyThemePreferences() {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first else { return }
let scheme = themeExpansion?.preferredColorScheme(for: selectedThemeIdentifier)
switch scheme {
case .some(.dark):
window.overrideUserInterfaceStyle = .dark
case .some(.light):
window.overrideUserInterfaceStyle = .light
default:
window.overrideUserInterfaceStyle = .unspecified
}
}
@ -344,34 +589,25 @@ struct DisplayView: View {
// MARK: - Theme Preview Card
private struct ThemePreviewCard: View {
let theme: AppTheme
let style: BackgroundStyle
let title: String
let selected: Bool
let action: () -> Void
private var hardcodedGradient: some View {
LinearGradient(
gradient: Gradient(colors: [
Color(UIColor.systemBackground),
Color(UIColor.secondarySystemBackground)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
var body: some View {
Button(action: action) {
ZStack {
hardcodedGradient
ThemedBackground(style: style)
.overlay(
RoundedRectangle(cornerRadius: 12)
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(.ultraThinMaterial)
.padding(8)
.padding(6)
.opacity(0.55)
)
.clipShape(RoundedRectangle(cornerRadius: 12))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
VStack(spacing: 6) {
Text(theme.displayName)
Text(title)
.font(.footnote.weight(.semibold))
.foregroundColor(.primary)
.padding(.horizontal, 8)
@ -382,7 +618,7 @@ private struct ThemePreviewCard: View {
}
.frame(height: 120)
.overlay(
RoundedRectangle(cornerRadius: 12)
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(selected ? Color.accentColor : Color.white.opacity(0.12), lineWidth: selected ? 2 : 1)
)
.shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2)
@ -390,3 +626,163 @@ private struct ThemePreviewCard: View {
.buttonStyle(.plain)
}
}
// MARK: - Custom Theme Editor
private struct CustomThemeEditorView: View {
@Environment(\.dismiss) private var dismiss
@State private var name: String
@State private var style: CustomThemeStyle
@State private var colors: [Color]
@State private var appearance: AppearanceOption
let onSave: (CustomTheme) -> Void
let onDelete: (() -> Void)?
private let maxColors = 4
init(initialTheme: CustomTheme?,
onSave: @escaping (CustomTheme) -> Void,
onDelete: (() -> Void)? = nil) {
self.onSave = onSave
self.onDelete = onDelete
if let theme = initialTheme {
_name = State(initialValue: theme.name)
_style = State(initialValue: theme.style)
let baseColors = theme.gradientColors
_colors = State(initialValue: baseColors.isEmpty ? [Color.blue, Color.purple] : baseColors)
_appearance = State(initialValue: AppearanceOption(theme.preferredColorScheme))
self.initialTheme = theme
} else {
_name = State(initialValue: "")
_style = State(initialValue: .staticGradient)
_colors = State(initialValue: [Color(hex: "#3E4C7C") ?? .indigo,
Color(hex: "#1C1F3A") ?? .blue])
_appearance = State(initialValue: .system)
self.initialTheme = nil
}
}
private let initialTheme: CustomTheme?
var body: some View {
NavigationStack {
Form {
Section(header: Text("Details")) {
TextField("Theme Name", text: $name)
.textInputAutocapitalization(.words)
Picker("Style", selection: $style) {
ForEach(CustomThemeStyle.allCases) { style in
Text(style.displayName).tag(style)
}
}
Picker("Appearance", selection: $appearance) {
ForEach(AppearanceOption.allCases) { option in
Text(option.title).tag(option)
}
}
.pickerStyle(.segmented)
}
Section(header: Text("Colors")) {
ForEach(colors.indices, id: \.self) { index in
HStack {
ColorPicker("", selection: Binding(get: {
colors[index]
}, set: { newValue in
if index < colors.count {
colors[index] = newValue
}
}), supportsOpacity: false)
.labelsHidden()
if colors.count > 2 {
Button(role: .destructive) {
colors.remove(at: index)
} label: {
Image(systemName: "minus.circle")
}
.padding(.leading, 4)
}
}
}
if colors.count < maxColors {
Button {
colors.append(colors.last ?? .blue)
} label: {
Label("Add Color", systemImage: "plus.circle")
}
}
}
if let onDelete {
Section {
Button("Delete Theme", role: .destructive) {
onDelete()
dismiss()
}
}
}
}
.navigationTitle(initialTheme == nil ? "New Theme" : "Edit Theme")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
let hexes = colors.compactMap { $0.toHex() ?? "#3E4C7C" }
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
let finalName = trimmed.isEmpty ? "Untitled Theme" : trimmed
let theme = CustomTheme(id: initialTheme?.id ?? UUID(),
name: finalName,
style: style,
colorHexes: hexes,
preferredColorScheme: appearance.colorScheme)
onSave(theme)
dismiss()
}
.disabled(colors.count < 2 || colors.allSatisfy { $0.toHex() == nil })
}
}
}
}
private enum AppearanceOption: String, CaseIterable, Identifiable {
case system
case light
case dark
var id: String { rawValue }
var title: String {
switch self {
case .system: return "System"
case .light: return "Light"
case .dark: return "Dark"
}
}
var colorScheme: ColorScheme? {
switch self {
case .system: return nil
case .light: return .light
case .dark: return .dark
}
}
init(_ scheme: ColorScheme?) {
switch scheme {
case .some(.light): self = .light
case .some(.dark): self = .dark
default: self = .system
}
}
}
}

View file

@ -25,6 +25,8 @@ struct HomeView: View {
@Environment(\.accentColor) private var environmentAccentColor
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@AppStorage("bundleID") private var bundleID: String = ""
@AppStorage("recentApps") private var recentApps: [String] = []
@AppStorage("favoriteApps") private var favoriteApps: [String] = []
@State private var isProcessing = false
@State private var isShowingInstalledApps = false
@State private var isShowingPairingFilePicker = false
@ -32,12 +34,12 @@ struct HomeView: View {
@State private var showPairingFileMessage = false
@State private var pairingFileIsValid = false
@State private var isImportingFile = false
@State private var showingConsoleLogsView = false
@State private var importProgress: Float = 0.0
@State private var showPIDSheet = false
@AppStorage("recentPIDs") private var recentPIDs: [Int] = []
@State private var justCopied = false
@State private var showingConsoleLogsView = false
@State private var viewDidAppeared = false
@State private var pendingJITEnableConfiguration : JITEnableConfiguration? = nil
@ -52,15 +54,18 @@ struct HomeView: View {
@StateObject private var tunnel = TunnelManager.shared
@State private var heartbeatOK = false
@State private var cachedAppNames: [String: String] = [:]
@State private var isLoadingQuickApps = false
@AppStorage("showiOS26Disclaimer") private var showiOS26Disclaimer: Bool = true
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
private var currentTheme: AppTheme { AppTheme(rawValue: appThemeRaw) ?? .system }
@Environment(\.themeExpansionManager) private var themeExpansion
private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) }
private var accentColor: Color {
if customAccentColorHex.isEmpty { return .white }
return Color(hex: customAccentColorHex) ?? .white
themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue
}
private var ddiMounted: Bool { isMounted() }
@ -74,7 +79,7 @@ struct HomeView: View {
var body: some View {
NavigationStack {
ZStack {
ThemedBackground(style: currentTheme.backgroundStyle)
ThemedBackground(style: backgroundStyle)
.ignoresSafeArea()
ScrollView {
@ -84,7 +89,8 @@ struct HomeView: View {
.transition(.opacity.combined(with: .move(edge: .top)))
}
topStatusAndActionsCard
readinessCard
quickConnectCard
toolsCard
tipsCard
}
@ -116,9 +122,11 @@ struct HomeView: View {
}
.navigationTitle("Home")
}
.preferredColorScheme(preferredScheme)
.onAppear {
checkPairingFileExists()
refreshBackground()
loadAppListIfNeeded()
NotificationCenter.default.addObserver(
forName: NSNotification.Name("ShowPairingFilePicker"),
object: nil,
@ -130,6 +138,19 @@ struct HomeView: View {
checkPairingFileExists()
heartbeatOK = pubHeartBeat
}
.onChange(of: pairingFileExists) { _, newValue in
if newValue {
loadAppListIfNeeded(force: cachedAppNames.isEmpty)
} else {
cachedAppNames = [:]
}
}
.onChange(of: favoriteApps) { _, _ in
loadAppListIfNeeded()
}
.onChange(of: recentApps) { _, _ in
loadAppListIfNeeded()
}
.fileImporter(isPresented: $isShowingPairingFilePicker, allowedContentTypes: [UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, .propertyList]) { result in
switch result {
case .success(let url):
@ -177,6 +198,13 @@ struct HomeView: View {
print("Failed to import file: \(error)")
}
}
.sheet(isPresented: $showingConsoleLogsView) {
if let manager = themeExpansion {
ConsoleLogsView().themeExpansionManager(manager)
} else {
ConsoleLogsView()
}
}
.sheet(isPresented: $isShowingInstalledApps) {
InstalledAppsListView { selectedBundle in
bundleID = selectedBundle
@ -291,104 +319,202 @@ struct HomeView: View {
// MARK: - Styled Sections
private var topStatusAndActionsCard: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 12) {
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()
}
VStack(spacing: 4) {
Text("Welcome, \(username)")
.font(.system(.title2, design: .rounded).weight(.bold))
.foregroundColor(.primary)
.frame(maxWidth: .infinity, alignment: .leading)
Text(helperSubtitle())
.font(.system(.subheadline, design: .rounded))
.foregroundColor(.secondary)
}
.padding(.bottom, 4)
VStack(spacing: 10) {
Button(action: primaryActionTapped) {
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)
if pairingFileExists && enableAdvancedOptions {
Button(action: { showPIDSheet = true }) {
whiteCardButtonLabel(icon: "number.circle", title: "Connect by PID")
private var readinessCard: some View {
homeCard {
VStack(alignment: .leading, spacing: 16) {
let summary = readinessSummary
HStack(alignment: .top, spacing: 12) {
StatusGlyph(icon: summary.icon, tint: summary.tint)
VStack(alignment: .leading, spacing: 6) {
Text("Welcome, \(username)")
.font(.system(.title2, design: .rounded).weight(.bold))
.foregroundStyle(.primary)
Text(summary.title)
.font(.headline.weight(.semibold))
.foregroundStyle(summary.tint)
Text(summary.subtitle)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
VStack(alignment: .leading, spacing: 12) {
ForEach(readinessChecklist) { item in
ChecklistRow(item: item, accentColor: accentColor)
}
}
VStack(spacing: 10) {
Button(action: primaryActionTapped) {
whiteCardButtonLabel(icon: primaryActionIcon, title: primaryActionTitle, isLoading: isProcessing)
}
.disabled(isProcessing)
if pairingFileExists && enableAdvancedOptions {
Button(action: { showPIDSheet = true }) {
secondaryButtonLabel(icon: "number.circle", title: "Connect by PID")
}
.disabled(isProcessing)
}
}
if isImportingFile {
pairingImportProgressView
} else if showPairingFileMessage && pairingFileIsValid {
pairingSuccessMessage
}
}
}
}
private var readinessSummary: ReadinessSummary {
if !pairingFileExists {
return .init(
title: "Import your pairing file",
subtitle: "Grab the pairing file from your computer to unlock app discovery.",
icon: "doc.badge.plus",
tint: .orange
)
}
if !ddiMounted {
return .init(
title: "Mount the Developer Disk Image",
subtitle: "Open Settings → Developer Disk Image and follow the prompts before connecting.",
icon: "externaldrive.badge.exclamationmark",
tint: .yellow
)
}
if tunnel.tunnelStatus != .connected {
return .init(
title: "Connect the VPN",
subtitle: "Tap Allow when iOS prompts you, then flip the toggle in Settings if needed.",
icon: "lock.slash",
tint: .orange
)
}
if !heartbeatOK {
return .init(
title: "Waiting for heartbeat",
subtitle: "StikDebug is reaching out to your device. We’ll connect automatically once it responds.",
icon: "waveform.path.ecg",
tint: .orange
)
}
return .init(
title: "Ready when you are",
subtitle: "Everything is ready. Select an app to enable debugging or JIT in seconds.",
icon: "bolt.horizontal.circle.fill",
tint: .green
)
}
private var primaryActionTitle: String {
if !pairingFileExists { return "Import Pairing File" }
if !ddiMounted { return "Mount Developer Disk Image" }
return "Connect by App"
}
private var primaryActionIcon: String {
if !pairingFileExists { return "doc.badge.plus" }
if !ddiMounted { return "externaldrive" }
return "cable.connector.horizontal"
}
private var readinessChecklist: [ChecklistItem] {
let vpnConnected = tunnel.tunnelStatus == .connected
return [
ChecklistItem(
title: "Pairing file",
subtitle: pairingFileExists ? "Imported and valid." : "Import the pairing file generated from your trusted computer.",
status: pairingFileExists ? .ready : .actionRequired,
actionTitle: pairingFileExists ? nil : "Import",
action: pairingFileExists ? nil : { isShowingPairingFilePicker = true }
),
ChecklistItem(
title: "Developer Disk Image",
subtitle: ddiMounted ? "Mounted successfully." : "Open Settings → Developer Disk Image and tap Mount.",
status: ddiMounted ? .ready : .attention,
actionTitle: nil,
action: nil
),
ChecklistItem(
title: "VPN tunnel",
subtitle: vpnConnected ? "Active." : "Connect the StikDebug VPN from the Settings app if it’s not already on.",
status: vpnConnected ? .ready : .attention,
actionTitle: nil,
action: nil
),
ChecklistItem(
title: "Heartbeat",
subtitle: heartbeatOK ? "Active." : "We retry automatically—leave the app open for a moment.",
status: heartbeatOK ? .ready : .waiting,
actionTitle: nil,
action: nil
)
]
}
private var pairingImportProgressView: some View {
VStack(spacing: 8) {
HStack {
Text("Processing pairing file…")
.font(.system(.caption, design: .rounded))
.foregroundStyle(.secondary)
Spacer()
Text("\(Int(importProgress * 100))%")
.font(.system(.caption, design: .rounded))
.foregroundStyle(.secondary)
}
if isImportingFile {
VStack(spacing: 8) {
HStack {
Text("Processing pairing file…")
.font(.system(.caption, design: .rounded))
.foregroundColor(.secondary)
Spacer()
Text("\(Int(importProgress * 100))%")
.font(.system(.caption, design: .rounded))
.foregroundColor(.secondary)
}
GeometryReader { geo in
ZStack(alignment: .leading) {
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)
}
}
.frame(height: 8)
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color(UIColor.tertiarySystemFill))
.frame(height: 8)
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(accentColor)
.frame(width: geo.size.width * CGFloat(importProgress), height: 8)
.animation(.linear(duration: 0.25), value: importProgress)
}
.padding(.top, 4)
} else if showPairingFileMessage && pairingFileIsValid {
HStack(spacing: 10) {
StatusDot(color: .green)
Text("Pairing file successfully imported")
.font(.system(.callout, design: .rounded))
.foregroundColor(.green)
}
.padding(.top, 4)
}
.frame(height: 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)
.accessibilityElement(children: .combine)
}
private func indicatorCapsule(ok: Bool, systemImage: String, a11y: String) -> some View {
HStack(spacing: 8) {
StatusDot(color: ok ? .green : .orange)
Image(systemName: systemImage)
.font(.subheadline.weight(.semibold))
.foregroundColor(.primary)
.accessibilityLabel(a11y)
private var pairingSuccessMessage: some View {
HStack(spacing: 10) {
StatusDot(color: .green)
Text("Pairing file successfully imported")
.font(.system(.callout, design: .rounded))
.foregroundStyle(.green)
Spacer(minLength: 0)
}
.frame(height: 32)
.padding(.horizontal, 10)
.background(Capsule(style: .continuous).fill(Color(UIColor.tertiarySystemBackground)))
.padding(.top, 4)
.transition(.opacity)
}
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)
private func whiteCardButtonLabel(icon: String, title: String, isLoading: Bool = false) -> some View {
HStack(spacing: 10) {
if isLoading {
ProgressView()
.progressViewStyle(.circular)
.tint(accentColor.contrastText())
.frame(width: 20, height: 20)
} else {
Image(systemName: icon)
.font(.system(size: 20, weight: .semibold, design: .rounded))
}
Text(title)
.font(.system(.title3, design: .rounded).weight(.semibold))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
@ -398,108 +524,236 @@ struct HomeView: View {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.white.opacity(0.2), lineWidth: 0.5)
)
.animation(.easeInOut(duration: 0.2), value: isLoading)
}
private var toolsCard: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Tools").font(.headline).foregroundColor(.secondary)
Button(action: { showingConsoleLogsView = true }) {
HStack {
Image(systemName: "terminal").font(.system(size: 20))
Text("Open Console").font(.system(.title3, design: .rounded)).fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(accentColor, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.foregroundColor(accentColor.contrastText())
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.white.opacity(0.2), lineWidth: 0.5)
)
}
.sheet(isPresented: $showingConsoleLogsView) { ConsoleLogsView() }
private func secondaryButtonLabel(icon: String, title: String) -> some View {
HStack {
Image(systemName: icon)
.font(.system(size: 18, weight: .semibold, design: .rounded))
Text(title)
.font(.system(.title3, design: .rounded).weight(.semibold))
}
.padding(20)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
)
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(UIColor.secondarySystemBackground).opacity(0.6))
)
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.white.opacity(0.12), lineWidth: 1)
)
.foregroundStyle(.primary)
}
private var tipsCard: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Tips").font(.headline).foregroundColor(.secondary)
if !pairingFileExists {
tipRow(systemImage: "doc.badge.plus", title: "Pairing file required", message: "Import your device’s pairing file to begin.")
}
if pairingFileExists && !ddiMounted {
tipRow(systemImage: "externaldrive.badge.exclamationmark", title: "Developer Disk Image not mounted", message: "Go to Settings → Developer Disk Image and ensure it’s mounted.")
}
tipRow(systemImage: "lock.shield", title: "Local only", message: "StikDebug runs entirely on-device. No data leaves your device.")
Button {
if let url = URL(string: "https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md") {
UIApplication.shared.open(url)
}
} label: {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "questionmark.circle")
.foregroundColor(accentColor)
.font(.system(size: 18))
VStack(alignment: .leading, spacing: 2) {
Text("Pairing File Guide")
.font(.subheadline).fontWeight(.semibold)
Text("Learn how to create and import your pairing file.")
.font(.footnote)
.foregroundColor(.secondary)
private var quickConnectCard: some View {
homeCard {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 8) {
Text("Quick Connect")
.font(.headline.weight(.semibold))
.foregroundStyle(.primary)
if isLoadingQuickApps {
ProgressView()
.progressViewStyle(.circular)
.controlSize(.small)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 14))
.foregroundColor(.secondary)
.padding(.top, 2)
}
.padding(.vertical, 4)
Text("Favorites and recents stay within reach so you can enable JIT without leaving Home.")
.font(.footnote)
.foregroundStyle(.secondary)
if quickConnectItems.isEmpty {
VStack(alignment: .leading, spacing: 10) {
Text("Pin apps from the Installed Apps list to see them here.")
.font(.caption)
.foregroundStyle(.secondary)
Button {
isShowingInstalledApps = true
} label: {
secondaryButtonLabel(icon: "star", title: "Choose Favorites")
}
.buttonStyle(.plain)
}
} else {
VStack(spacing: 10) {
ForEach(quickConnectItems) { item in
QuickConnectRow(
item: item,
accentColor: accentColor,
isEnabled: canConnectByApp && !isProcessing,
action: {
HapticFeedbackHelper.trigger()
startJITInBackground(bundleID: item.bundleID,
pid: nil,
scriptData: nil,
scriptName: nil,
triggeredByURLScheme: false)
}
)
}
}
}
if !canConnectByApp {
Text("Finish the pairing and mounting steps above to enable quick launches.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
}
.padding(20)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
)
)
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
}
private var quickConnectItems: [QuickConnectItem] {
var seen = Set<String>()
var ordered: [QuickConnectItem] = []
for bundle in favoriteApps + recentApps {
guard seen.insert(bundle).inserted else { continue }
ordered.append(QuickConnectItem(bundleID: bundle, displayName: friendlyName(for: bundle)))
if ordered.count >= 4 { break }
}
return ordered
}
private func friendlyName(for bundleID: String) -> String {
if let cached = cachedAppNames[bundleID], !cached.isEmpty { return cached }
let components = bundleID.split(separator: ".")
if let last = components.last {
let cleaned = last.replacingOccurrences(of: "_", with: " ")
let trimmed = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed.capitalized }
}
return bundleID
}
private func loadAppListIfNeeded(force: Bool = false) {
guard pairingFileExists else {
cachedAppNames = [:]
isLoadingQuickApps = false
return
}
if isLoadingQuickApps { return }
if !force && !cachedAppNames.isEmpty { return }
isLoadingQuickApps = true
DispatchQueue.global(qos: .userInitiated).async {
let result = (try? JITEnableContext.shared.getAppList()) ?? [:]
DispatchQueue.main.async {
cachedAppNames = result
isLoadingQuickApps = false
}
}
}
private func homeCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
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)
}
private var toolsCard: some View {
homeCard {
VStack(alignment: .leading, spacing: 12) {
Text("Tools")
.font(.headline)
.foregroundStyle(.secondary)
VStack(spacing: 10) {
Button {
showingConsoleLogsView = true
} label: {
whiteCardButtonLabel(icon: "terminal", title: "Open Console")
}
// Button {
// isShowingInstalledApps = true
// } label: {
// secondaryButtonLabel(icon: "list.bullet", title: "Installed Apps")
// }
// .buttonStyle(.plain)
}
}
}
}
private var tipsCard: some View {
homeCard {
VStack(alignment: .leading, spacing: 12) {
Text("Tips")
.font(.headline)
.foregroundStyle(.secondary)
if !pairingFileExists {
tipRow(systemImage: "doc.badge.plus", title: "Pairing file required", message: "Import your device’s pairing file to begin.")
}
if pairingFileExists && !ddiMounted {
tipRow(systemImage: "externaldrive.badge.exclamationmark", title: "Developer Disk Image not mounted", message: "Go to Settings → Developer Disk Image and ensure it’s mounted.")
}
tipRow(systemImage: "lock.shield", title: "Local only", message: "StikDebug runs entirely on-device. No data leaves your device.")
Divider().background(Color.white.opacity(0.1))
Button {
if let url = URL(string: "https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md") {
UIApplication.shared.open(url)
}
} label: {
HStack(alignment: .center, spacing: 12) {
Image(systemName: "questionmark.circle")
.foregroundStyle(accentColor)
.font(.system(size: 18, weight: .semibold))
VStack(alignment: .leading, spacing: 2) {
Text("Pairing File Guide")
.font(.subheadline.weight(.semibold))
Text("Step-by-step instructions from the community wiki.")
.font(.footnote)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
.buttonStyle(.plain)
}
}
}
private func tipRow(systemImage: String, title: String, message: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: systemImage)
.foregroundColor(accentColor)
.font(.system(size: 18))
.foregroundStyle(accentColor)
.font(.system(size: 18, weight: .semibold))
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.subheadline).fontWeight(.semibold)
Text(message).font(.footnote).foregroundColor(.secondary)
Text(title)
.font(.subheadline.weight(.semibold))
Text(message)
.font(.footnote)
.foregroundStyle(.secondary)
}
Spacer()
Spacer(minLength: 0)
}
.padding(.vertical, 4)
}
private func helperSubtitle() -> String {
if canConnectByApp { return "Select an app to start debugging." }
if pairingFileExists { return "Mount the Developer Disk Image in Settings." }
return "Import your pairing file to get started."
}
private func primaryActionTapped() {
if pairingFileExists {
if !ddiMounted {
@ -688,6 +942,199 @@ private struct StatusDot: View {
}
}
private struct StatusGlyph: View {
let icon: String
let tint: Color
var size: CGFloat = 48
var iconSize: CGFloat = 22
var body: some View {
ZStack {
Circle()
.fill(tint.opacity(0.18))
.frame(width: size, height: size)
Image(systemName: icon)
.font(.system(size: iconSize, weight: .semibold, design: .rounded))
.foregroundStyle(tint)
}
.overlay(
Circle()
.stroke(Color.white.opacity(0.12), lineWidth: 0.5)
)
}
}
private struct ChecklistRow: View {
let item: ChecklistItem
let accentColor: Color
var body: some View {
HStack(alignment: .center, spacing: 12) {
StatusGlyph(icon: item.status.iconName, tint: item.status.tint, size: 40, iconSize: 18)
VStack(alignment: .leading, spacing: 4) {
Text(item.title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Text(item.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
if let actionTitle = item.actionTitle, let action = item.action {
Button(action: action) {
Text(actionTitle)
.font(.caption.weight(.semibold))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
Capsule(style: .continuous)
.fill(accentColor.opacity(0.18))
)
.overlay(
Capsule(style: .continuous)
.stroke(accentColor.opacity(0.4), lineWidth: 1)
)
}
.buttonStyle(.plain)
.foregroundStyle(accentColor)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(UIColor.secondarySystemBackground).opacity(0.55))
)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(Color.white.opacity(0.08), lineWidth: 1)
)
}
}
private struct QuickConnectRow: View {
let item: QuickConnectItem
let accentColor: Color
let isEnabled: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 14) {
QuickAppBadge(title: item.displayName, accentColor: accentColor)
VStack(alignment: .leading, spacing: 2) {
Text(item.displayName)
.font(.system(size: 16, weight: .semibold, design: .rounded))
.foregroundStyle(.primary)
.lineLimit(1)
Text(item.bundleID)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.textSelection(.enabled)
}
Spacer(minLength: 0)
Image(systemName: "bolt.horizontal.circle.fill")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(isEnabled ? accentColor : Color.secondary)
}
.padding(.vertical, 12)
.padding(.horizontal, 14)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(Color(UIColor.secondarySystemBackground).opacity(isEnabled ? 0.65 : 0.35))
)
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.stroke(Color.white.opacity(0.1), lineWidth: 1)
)
}
.buttonStyle(.plain)
.disabled(!isEnabled)
.opacity(isEnabled ? 1 : 0.55)
}
}
private struct QuickAppBadge: View {
let title: String
let accentColor: Color
private var initials: String {
let words = title.split(separator: " ")
if let first = words.first, !first.isEmpty {
return String(first.prefix(1)).uppercased()
}
return String(title.prefix(1)).uppercased()
}
var body: some View {
Text(initials)
.font(.system(size: 16, weight: .semibold, design: .rounded))
.frame(width: 36, height: 36)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(accentColor.opacity(0.16))
)
.foregroundStyle(accentColor)
}
}
private struct ReadinessSummary {
let title: String
let subtitle: String
let icon: String
let tint: Color
}
private enum ChecklistStatus {
case ready
case waiting
case attention
case actionRequired
var iconName: String {
switch self {
case .ready: return "checkmark.circle.fill"
case .waiting: return "clock.fill"
case .attention: return "exclamationmark.triangle.fill"
case .actionRequired: return "exclamationmark.octagon.fill"
}
}
var tint: Color {
switch self {
case .ready: return .green
case .waiting: return .orange
case .attention: return .yellow
case .actionRequired: return .red
}
}
}
private struct ChecklistItem: Identifiable {
let id = UUID()
let title: String
let subtitle: String
let status: ChecklistStatus
let actionTitle: String?
let action: (() -> Void)?
}
private struct QuickConnectItem: Identifiable {
let bundleID: String
let displayName: String
var id: String { bundleID }
}
// MARK: - Connect-by-PID Sheet (minus/plus removed)
private struct ConnectByPIDSheet: View {

View file

@ -30,16 +30,50 @@ struct InstalledAppsListView: View {
@AppStorage("performanceMode") private var performanceMode = false
@State private var showPerformanceToast = false
@State private var selectedTab: AppListTab = .debuggable
@State private var launchingBundles: Set<String> = []
@State private var launchFeedback: LaunchFeedback? = nil
@State private var debuggableSearchText: String = ""
@State private var otherSearchText: String = ""
@Environment(\.dismiss) private var dismiss
var onSelectApp: (String) -> Void
private var filteredRecents: [String] {
recentApps.filter { viewModel.debuggableApps[$0] != nil && !favoriteApps.contains($0) }
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
@Environment(\.themeExpansionManager) private var themeExpansion
private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) }
private var debuggableSearchIsActive: Bool {
!debuggableSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var debuggableSortedApps: [(key: String, value: String)] {
viewModel.debuggableApps.sorted { lhs, rhs in
let comparison = lhs.value.localizedCaseInsensitiveCompare(rhs.value)
if comparison == .orderedSame {
return lhs.key < rhs.key
}
return comparison == .orderedAscending
}
}
private var filteredDebuggableApps: [(key: String, value: String)] {
guard debuggableSearchIsActive else { return debuggableSortedApps }
let query = normalizedSearchString(debuggableSearchText)
guard !query.isEmpty else { return debuggableSortedApps }
return debuggableSortedApps.filter { matches(query, bundleID: $0.key, name: $0.value) }
}
private var filteredDebuggableSet: Set<String> {
Set(filteredDebuggableApps.map { $0.key })
}
private var filteredFavoriteBundles: [String] {
favoriteApps.filter { filteredDebuggableSet.contains($0) }
}
private var filteredRecentBundles: [String] {
recentApps.filter { filteredDebuggableSet.contains($0) && !favoriteApps.contains($0) }
}
private enum AppListTab: String, CaseIterable, Identifiable {
@ -79,19 +113,22 @@ struct InstalledAppsListView: View {
private var filteredOtherApps: [(key: String, value: String)] {
let base = sortedOtherApps
guard otherSearchIsActive else { return base }
let query = otherSearchText
.trimmingCharacters(in: .whitespacesAndNewlines)
let query = normalizedSearchString(otherSearchText)
guard !query.isEmpty else { return base }
return base.filter { matches(query, bundleID: $0.key, name: $0.value) }
}
private func normalizedSearchString(_ text: String) -> String {
text.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
.lowercased()
return base.filter {
let identifier = $0.key
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
.lowercased()
let name = $0.value
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
.lowercased()
return identifier.contains(query) || name.contains(query)
}
}
private func matches(_ normalizedQuery: String, bundleID: String, name: String) -> Bool {
guard !normalizedQuery.isEmpty else { return true }
let identifier = normalizedSearchString(bundleID)
let displayName = normalizedSearchString(name)
return identifier.contains(normalizedQuery) || displayName.contains(normalizedQuery)
}
private func isEmpty(for tab: AppListTab) -> Bool {
@ -106,7 +143,8 @@ struct InstalledAppsListView: View {
var body: some View {
NavigationStack {
ZStack {
Color.clear.ignoresSafeArea()
ThemedBackground(style: backgroundStyle)
.ignoresSafeArea()
if viewModel.isLoading {
ProgressView("Loading Apps…".localized)
@ -193,6 +231,7 @@ struct InstalledAppsListView: View {
}
}
}
.preferredColorScheme(preferredScheme)
}
// MARK: Empty State
@ -241,14 +280,18 @@ struct InstalledAppsListView: View {
// MARK: Apps List
private var debuggableSections: some View {
private func debuggableSections(
apps: [(key: String, value: String)],
favorites: [String],
recents: [String]
) -> some View {
VStack(spacing: 18) {
if !favoriteApps.isEmpty {
if !favorites.isEmpty {
glassSection(
title: String(format: "Favorites (%d/4)".localized, favoriteApps.count)
title: String(format: "Favorites (%d/4)".localized, favorites.count)
) {
LazyVStack(spacing: 12) {
ForEach(favoriteApps, id: \.self) { bundleID in
ForEach(favorites, id: \.self) { bundleID in
AppButton(
bundleID: bundleID,
appName: viewModel.debuggableApps[bundleID] ?? bundleID,
@ -264,10 +307,10 @@ struct InstalledAppsListView: View {
}
}
if !filteredRecents.isEmpty {
if !recents.isEmpty {
glassSection(title: "Recents".localized) {
LazyVStack(spacing: 12) {
ForEach(filteredRecents, id: \.self) { bundleID in
ForEach(recents, id: \.self) { bundleID in
AppButton(
bundleID: bundleID,
appName: viewModel.debuggableApps[bundleID] ?? bundleID,
@ -285,7 +328,7 @@ struct InstalledAppsListView: View {
glassSection(title: "All Applications".localized) {
LazyVStack(spacing: 12) {
ForEach(viewModel.debuggableApps.sorted(by: { $0.key < $1.key }), id: \.key) { bundleID, appName in
ForEach(apps, id: \.key) { bundleID, appName in
AppButton(
bundleID: bundleID,
appName: appName,
@ -331,22 +374,14 @@ struct InstalledAppsListView: View {
}
private var tabbedContent: some View {
TabView(selection: $selectedTab) {
tabContent(for: .debuggable)
.tag(AppListTab.debuggable)
.tabItem { Text(AppListTab.debuggable.title.localized) }
tabContent(for: .other)
.tag(AppListTab.other)
.tabItem { Text(AppListTab.other.title.localized) }
}
tabContent(for: .debuggable)
}
@ViewBuilder
private func tabContent(for tab: AppListTab) -> some View {
switch tab {
case .debuggable:
if isEmpty(for: .debuggable) {
if viewModel.debuggableApps.isEmpty {
VStack {
Spacer(minLength: 0)
emptyState(for: .debuggable)
@ -359,11 +394,22 @@ struct InstalledAppsListView: View {
} else {
ScrollView {
VStack(spacing: 18) {
debuggableSearchBar
if let error = viewModel.lastError {
errorBanner(error)
}
debuggableSections
if filteredDebuggableApps.isEmpty {
debuggableSearchEmptyState
.transition(.opacity.combined(with: .scale))
} else {
debuggableSections(
apps: filteredDebuggableApps,
favorites: filteredFavoriteBundles,
recents: filteredRecentBundles
)
}
}
.padding(.horizontal, 20)
.padding(.vertical, 24)
@ -400,6 +446,55 @@ struct InstalledAppsListView: View {
}
}
private var debuggableSearchBar: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search apps or bundle ID".localized, text: $debuggableSearchText)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
if debuggableSearchIsActive {
Button {
debuggableSearchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 16))
.foregroundStyle(.secondary)
}
.accessibilityLabel("Clear search".localized)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(.white.opacity(0.08), lineWidth: 1)
)
)
}
private var debuggableSearchEmptyState: some View {
VStack(spacing: 12) {
Image(systemName: "text.magnifyingglass")
.font(.system(size: 44, weight: .regular))
.foregroundStyle(.secondary)
Text("No matching apps".localized)
.font(.title3.weight(.semibold))
Text("Try a different name or bundle identifier.".localized)
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(24)
.glassCard(cornerRadius: 20, material: .thinMaterial, strokeOpacity: 0.12)
}
private var otherSearchEmptyState: some View {
VStack(spacing: 12) {
Image(systemName: "magnifyingglass")
@ -543,6 +638,8 @@ struct AppButton: View {
@AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true
@AppStorage("enableAdvancedOptions") private var enableAdvancedOptions = false
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
@Environment(\.themeExpansionManager) private var themeExpansion
var onSelectApp: (String) -> Void
let sharedDefaults: UserDefaults
@ -550,6 +647,8 @@ struct AppButton: View {
@State private var showScriptPicker = false
private var rowBackgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
var body: some View {
Button(action: selectApp) {
HStack(spacing: loadAppIconsOnJIT ? 16 : 12) {
@ -663,24 +762,7 @@ struct AppButton: View {
// 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)
}
}
ThemedRowBackground(performanceMode: performanceMode, style: rowBackgroundStyle, cornerRadius: 16)
}
// MARK: Actions
@ -760,10 +842,14 @@ struct LaunchAppRow: View {
@Binding var appIcons: [String: UIImage]
@AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
@Environment(\.themeExpansionManager) private var themeExpansion
let performanceMode: Bool
var launchAction: () -> Void
private var rowBackgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
var body: some View {
Button {
guard !isLaunching else { return }
@ -844,24 +930,7 @@ struct LaunchAppRow: View {
}
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)
}
}
ThemedRowBackground(performanceMode: performanceMode, style: rowBackgroundStyle, cornerRadius: 16)
}
private func loadAppIcon(for bundleID: String) {
@ -879,6 +948,75 @@ struct LaunchAppRow: View {
}
}
private struct ThemedRowBackground: View {
var performanceMode: Bool
var style: BackgroundStyle
var cornerRadius: CGFloat
var body: some View {
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
return Group {
if performanceMode {
shape
.fill(Color(.secondarySystemBackground).opacity(0.65))
.overlay(shape.stroke(Color.white.opacity(0.10), lineWidth: 1))
} else {
ZStack {
shape.fill(.ultraThinMaterial)
themedOverlay(shape: shape)
shape.stroke(Color.white.opacity(0.15), lineWidth: 1)
}
.shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 3)
}
}
}
@ViewBuilder
private func themedOverlay(shape: RoundedRectangle) -> some View {
switch style {
case .staticGradient(let colors), .customGradient(let colors):
shape
.fill(
LinearGradient(
gradient: Gradient(colors: normalized(colors)),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.opacity(0.32)
case .animatedGradient(let colors, _):
shape
.fill(
LinearGradient(
gradient: Gradient(colors: normalized(colors)),
startPoint: .leading,
endPoint: .trailing
)
)
.opacity(0.38)
case .blobs(let colors, _):
shape
.fill(
LinearGradient(
gradient: Gradient(colors: normalized(colors)),
startPoint: .top,
endPoint: .bottom
)
)
.opacity(0.40)
case .particles(let particle, _):
shape.fill(particle.opacity(0.18))
}
}
private func normalized(_ colors: [Color]) -> [Color] {
if colors.count >= 2 { return colors }
if let first = colors.first { return [first, first.opacity(0.6)] }
return [Color.blue, Color.purple]
}
}
final class IconCache {
static let shared = IconCache()

View file

@ -9,14 +9,21 @@ import SwiftUI
struct MainTabView: View {
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
@State private var selection: Int = 0
// Update checking
@State private var showForceUpdate: Bool = false
@State private var latestVersion: String? = nil
@Environment(\.themeExpansionManager) private var themeExpansion
private var accentColor: Color {
customAccentColorHex.isEmpty ? .white : (Color(hex: customAccentColorHex) ?? .white)
themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue
}
private var preferredScheme: ColorScheme? {
themeExpansion?.preferredColorScheme(for: appThemeRaw)
}
var body: some View {
@ -42,9 +49,9 @@ struct MainTabView: View {
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
.tag(4)
}
.id(customAccentColorHex)
.id((themeExpansion?.hasThemeExpansion == true) ? customAccentColorHex : "default-accent")
.tint(accentColor)
.preferredColorScheme(.dark)
.preferredColorScheme(preferredScheme)
.onAppear {
checkForUpdate()
}
@ -143,5 +150,6 @@ struct MainTabView: View {
struct MainTabView_Previews: PreviewProvider {
static var previews: some View {
MainTabView()
.themeExpansionManager(ThemeExpansionManager(previewUnlocked: true))
}
}

View file

@ -17,20 +17,22 @@ struct SettingsView: View {
@AppStorage("enablePiP") private var enablePiP = false
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
private var currentTheme: AppTheme { AppTheme(rawValue: appThemeRaw) ?? .system }
@Environment(\.themeExpansionManager) private var themeExpansion
private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) }
@State private var isShowingPairingFilePicker = false
@Environment(\.colorScheme) private var colorScheme
@State private var showIconPopover = false
@State private var showPairingFileMessage = false
@State private var pairingFileIsValid = false
@State private var isImportingFile = false
@State private var importProgress: Float = 0.0
@State private var pairingStatusMessage: String? = nil
@State private var showRemovePairingFileDialog = false
@State private var is_lc = false
@State private var showColorPickerPopup = false
@State private var showingConsoleLogsView = false
@State private var showingDisplayView = false
private var appVersion: String {
@ -39,8 +41,15 @@ struct SettingsView: View {
}
private var accentColor: Color {
if customAccentColorHex.isEmpty { return .white }
return Color(hex: customAccentColorHex) ?? .white
themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue
}
private var currentThemeName: String {
AppTheme(rawValue: appThemeRaw)?.displayName ?? "Default"
}
private var accentColorDescription: String {
customAccentColorHex.isEmpty ? "System Blue" : customAccentColorHex.uppercased()
}
// Developer profile image URLs
private let developerProfiles: [String: String] = [
@ -57,7 +66,7 @@ struct SettingsView: View {
NavigationStack {
ZStack {
// Subtle depth gradient background
ThemedBackground(style: currentTheme.backgroundStyle)
ThemedBackground(style: backgroundStyle)
.ignoresSafeArea()
ScrollView {
@ -112,10 +121,12 @@ struct SettingsView: View {
}
// Success toast after import
if showPairingFileMessage && pairingFileIsValid && !isImportingFile {
if let pairingStatusMessage,
showPairingFileMessage,
!isImportingFile {
VStack {
Spacer()
Text("✓ Pairing file successfully imported")
Text(pairingStatusMessage)
.font(.footnote.weight(.semibold))
.padding(.horizontal, 14)
.padding(.vertical, 10)
@ -130,8 +141,9 @@ struct SettingsView: View {
}
.navigationTitle("Settings")
}
// Force a white tint in Settings, overriding any global/user tint
.tint(Color.white)
// Match controls to the active accent color (defaults to blue)
.tint(accentColor)
.preferredColorScheme(preferredScheme)
.fileImporter(
isPresented: $isShowingPairingFilePicker,
allowedContentTypes: [UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, .propertyList],
@ -156,7 +168,8 @@ struct SettingsView: View {
DispatchQueue.main.async {
isImportingFile = true
importProgress = 0.0
pairingFileIsValid = false
pairingStatusMessage = nil
showPairingFileMessage = false
}
let progressTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { timer in
@ -166,17 +179,6 @@ struct SettingsView: View {
} else {
timer.invalidate()
isImportingFile = false
pairingFileIsValid = true
withAnimation {
showPairingFileMessage = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
withAnimation {
showPairingFileMessage = false
}
}
}
}
}
@ -198,7 +200,6 @@ struct SettingsView: View {
print("Failed to import file: \(error)")
}
}
.preferredColorScheme(.dark)
}
// MARK: - Cards
@ -228,17 +229,38 @@ struct SettingsView: View {
private var appearanceCard: some View {
glassCard {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 16) {
Text("Appearance")
.font(.headline)
.foregroundColor(.primary)
HStack(spacing: 14) {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(accentColor)
.frame(width: 44, height: 44)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.2), lineWidth: 1)
)
VStack(alignment: .leading, spacing: 4) {
Text(currentThemeName)
.font(.subheadline.weight(.semibold))
.foregroundColor(.primary)
Text("Accent · \(accentColorDescription)")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.vertical, 4)
Button(action: { showingDisplayView = true }) {
HStack {
Image(systemName: "paintbrush")
.font(.system(size: 18))
.foregroundColor(.primary.opacity(0.85))
Text("Display")
Text("Customize Display")
.foregroundColor(.primary.opacity(0.85))
Spacer()
Image(systemName: "chevron.right")
@ -251,8 +273,11 @@ struct SettingsView: View {
.padding(4)
}
.sheet(isPresented: $showingDisplayView) {
DisplayView()
.preferredColorScheme(.dark)
if let manager = themeExpansion {
DisplayView().themeExpansionManager(manager)
} else {
DisplayView()
}
}
}
@ -282,7 +307,7 @@ struct SettingsView: View {
)
}
if showPairingFileMessage && pairingFileIsValid && !isImportingFile {
if showPairingFileMessage && !isImportingFile {
HStack {
Image(systemName: "checkmark.circle.fill").foregroundColor(.green)
Text("Pairing file successfully imported")
@ -332,21 +357,6 @@ struct SettingsView: View {
.font(.headline)
.foregroundColor(.primary)
Button(action: { showingConsoleLogsView = true }) {
HStack {
Image(systemName: "terminal")
.font(.system(size: 18))
.foregroundColor(.primary.opacity(0.8))
Text("System Logs")
.foregroundColor(.primary.opacity(0.8))
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 14))
.foregroundColor(.white)
}
.padding(.vertical, 8)
}
Button(action: { openAppFolder() }) {
HStack {
Image(systemName: "folder")
@ -363,10 +373,6 @@ struct SettingsView: View {
}
}
}
.sheet(isPresented: $showingConsoleLogsView) {
ConsoleLogsView()
.preferredColorScheme(.dark)
}
}
private var helpCard: some View {
@ -549,14 +555,13 @@ struct LinkRow: View {
}
}
.padding(.vertical, 8)
.preferredColorScheme(.dark)
}
}
struct ConsoleLogsView_Preview: PreviewProvider {
static var previews: some View {
ConsoleLogsView()
.preferredColorScheme(.dark)
.themeExpansionManager(ThemeExpansionManager(previewUnlocked: true))
}
}

View file

@ -540,7 +540,7 @@ enum ManagerSection: String, CaseIterable, Identifiable {
struct GlassLiquidTabSwitcher: View {
@Binding var selection: ManagerSection
var accent: Color = .white
var accent: Color = .blue
var height: CGFloat = 56
private let pad: CGFloat = 6
@ -685,8 +685,9 @@ private struct ManageCertsView: View {
struct IPAAppManagerView: View {
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
@Environment(\.themeExpansionManager) private var themeExpansion
private var accent: Color {
customAccentColorHex.isEmpty ? .white : Color(hex: customAccentColorHex) ?? .white
themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue
}
@StateObject private var mgr = AppSignerManager()