mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +00:00
2.3.0 RC 2 (Very WIP)
This commit is contained in:
parent
34532c7f6a
commit
e90ba11535
16 changed files with 2008 additions and 914 deletions
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -74,6 +74,9 @@
|
|||
ReferencedContainer = "container:StikDebug.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../../StikDebug.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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("")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
322
StikJIT/Utilities/ThemeExpansionManager.swift
Normal file
322
StikJIT/Utilities/ThemeExpansionManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue