diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..9e121fd7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,66 @@ +name: Bug Report +description: Report a bug or unexpected behavior +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug. Please fill out the information below to help us fix it. + - type: textarea + id: description + attributes: + label: Description + description: A clear description of the bug. + placeholder: Describe what happened... + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to Reproduce + description: How can we reproduce this? + placeholder: | + 1. Go to ... + 2. Tap on ... + 3. See error + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What did you expect to happen? + validations: + required: true + - type: input + id: ios-version + attributes: + label: iOS Version + placeholder: e.g. 18.3.1 + validations: + required: true + - type: input + id: device + attributes: + label: Device + placeholder: e.g. iPhone 15 Pro + validations: + required: true + - type: input + id: app-version + attributes: + label: StikDebug Version + placeholder: e.g. 1.0.0 + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs + description: Any relevant logs from the app's log viewer. + render: shell + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: If applicable, add screenshots to help explain the issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..41f33266 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,27 @@ +name: Feature Request +description: Suggest a new feature or improvement +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Have an idea for StikDebug? Let us know! + - type: textarea + id: description + attributes: + label: Description + description: A clear description of the feature you'd like. + validations: + required: true + - type: textarea + id: use-case + attributes: + label: Use Case + description: Why would this be useful? What problem does it solve? + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Have you considered any alternative solutions or workarounds? diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..3474079e --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ +## Summary + + + +## Changes + +- + +## Related Issues + + + +## Testing + +- [ ] Tested on device +- [ ] Tested with fresh pairing file +- [ ] No regressions in existing functionality + +## Screenshots + + diff --git a/.github/workflows/build_ipa.yml b/.github/workflows/build_ipa.yml index b3c37ad9..96a09dd1 100644 --- a/.github/workflows/build_ipa.yml +++ b/.github/workflows/build_ipa.yml @@ -58,12 +58,18 @@ jobs: path: StikDebug.ipa retention-days: 90 - - name: Create or Update GitHub Release (GitHub-Alpha) + - name: Read app version + id: version + run: | + VERSION=$(sed -n 's/.*MARKETING_VERSION = \(.*\);/\1/p' StikDebug.xcodeproj/project.pbxproj | head -1 | xargs) + echo "app_version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Create or Update GitHub Release (Nightly) if: env.UPLOAD_IPA == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main' uses: softprops/action-gh-release@v2 with: - tag_name: GitHub-Alpha - name: GitHub-Alpha + tag_name: Nightly + name: "Nightly - v${{ steps.version.outputs.app_version }}" prerelease: true generate_release_notes: false target_commitish: ${{ github.sha }} diff --git a/DebugWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/DebugWidget/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index eb878970..00000000 --- a/DebugWidget/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/DebugWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/DebugWidget/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 23058801..00000000 --- a/DebugWidget/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "tinted" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/DebugWidget/Assets.xcassets/Contents.json b/DebugWidget/Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596..00000000 --- a/DebugWidget/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/DebugWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/DebugWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json deleted file mode 100644 index eb878970..00000000 --- a/DebugWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/DebugWidget/DebugWidget.swift b/DebugWidget/DebugWidget.swift deleted file mode 100644 index 5f2e19b3..00000000 --- a/DebugWidget/DebugWidget.swift +++ /dev/null @@ -1,270 +0,0 @@ -// -// DebugWidget.swift -// DebugWidget -// -// Created by Stephen on 5/30/25. -// - -import WidgetKit -import SwiftUI -import UIKit - -// MARK: - Favorites Widget ---------------------------------------------------- - -struct FavoriteSnapshot: Identifiable { - let bundleID: String - let displayName: String - var id: String { bundleID } -} - -struct FavoritesEntry: TimelineEntry { - let date: Date - let items: [FavoriteSnapshot] -} - -struct FavoritesProvider: TimelineProvider { - private let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj") - - func placeholder(in context: Context) -> FavoritesEntry { - FavoritesEntry(date: .now, items: []) - } - - func getSnapshot(in context: Context, completion: @escaping (FavoritesEntry) -> Void) { - completion(makeEntry()) - } - - func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - let entry = makeEntry() - completion(Timeline(entries: [entry], policy: .never)) - } - - private func makeEntry() -> FavoritesEntry { - let favorites = sharedDefaults?.stringArray(forKey: "favoriteApps") ?? [] - let names = sharedDefaults?.dictionary(forKey: "favoriteAppNames") as? [String: String] ?? [:] - let items = favorites.prefix(4).map { bundleID -> FavoriteSnapshot in - let display = names[bundleID] ?? friendlyNameFromBundleID(bundleID) - return FavoriteSnapshot(bundleID: bundleID, displayName: display) - } - return FavoritesEntry(date: .now, items: items) - } -} - -struct FavoritesWidgetEntryView: View { - let entry: FavoritesEntry - - var body: some View { - HStack(spacing: 8) { - ForEach(0..<4, id: \.self) { idx in - if idx < entry.items.count { - labeledIconCell(item: entry.items[idx]) - } else { - placeholderCell() - .frame(maxWidth: .infinity) - } - } - } - .padding(8) - .containerBackground(Color(UIColor.systemBackground), for: .widget) - } - - @ViewBuilder - private func labeledIconCell(item: FavoriteSnapshot) -> some View { - if let img = loadIcon(for: item.bundleID) { - Link(destination: URL(string: "stikjit://enable-jit?bundle-id=\(item.bundleID)")!) { - VStack(spacing: 6) { - Image(uiImage: img) - .resizable() - .aspectRatio(1, contentMode: .fit) - .cornerRadius(12) - Text(item.displayName) - .font(.caption2) - .lineLimit(1) - .minimumScaleFactor(0.65) - .foregroundStyle(.primary) - } - .frame(maxWidth: .infinity) - } - } else { - placeholderCell() - .frame(maxWidth: .infinity) - } - } - - @ViewBuilder - private func placeholderCell() -> some View { - VStack(spacing: 6) { - ZStack { - RoundedRectangle(cornerRadius: 12) - .fill(Color(UIColor.systemGray5)) - Image(systemName: "plus") - .font(.system(size: 24, weight: .medium)) - .foregroundColor(.gray) - } - .aspectRatio(1, contentMode: .fit) - Text(" ") - .font(.caption2) - .opacity(0) // keep height consistent - } - } -} - -struct FavoritesWidget: Widget { - let kind: String = "FavoritesWidget" - - var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: FavoritesProvider()) { entry in - FavoritesWidgetEntryView(entry: entry) - } - .configurationDisplayName("StikDebug Favorites") - .description("Quick-launch your top 4 favorite debug targets.") - .supportedFamilies([.systemMedium]) - } -} - -// MARK: - Launch Shortcuts Widget (formerly System Apps) --------------------- - -struct SystemAppSnapshot: Identifiable { - let bundleID: String - let displayName: String - var id: String { bundleID } -} - -struct SystemAppsEntry: TimelineEntry { - let date: Date - let items: [SystemAppSnapshot] -} - -struct SystemAppsProvider: TimelineProvider { - private let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj") - - func placeholder(in context: Context) -> SystemAppsEntry { - SystemAppsEntry(date: .now, items: []) - } - - func getSnapshot(in context: Context, completion: @escaping (SystemAppsEntry) -> Void) { - completion(makeEntry()) - } - - func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - let entry = makeEntry() - completion(Timeline(entries: [entry], policy: .never)) - } - - private func makeEntry() -> SystemAppsEntry { - // This list now represents "pinned launch apps" (system or other) - let pinned = sharedDefaults?.stringArray(forKey: "pinnedSystemApps") ?? [] - let names = sharedDefaults?.dictionary(forKey: "pinnedSystemAppNames") as? [String: String] ?? [:] - let snapshots = pinned.prefix(4).map { bundleID -> SystemAppSnapshot in - let displayName = names[bundleID] ?? friendlyName(bundleID: bundleID) - return SystemAppSnapshot(bundleID: bundleID, displayName: displayName) - } - return SystemAppsEntry(date: .now, items: snapshots) - } - - private func friendlyName(bundleID: String) -> String { - 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 - } -} - -struct SystemAppsWidgetEntryView: View { - let entry: SystemAppsEntry - - var body: some View { - HStack(spacing: 8) { - ForEach(0..<4, id: \.self) { idx in - if idx < entry.items.count { - labeledIconCell(item: entry.items[idx]) - } else { - placeholderCell() - .frame(maxWidth: .infinity) - } - } - } - .padding(8) - .containerBackground(Color(UIColor.systemBackground), for: .widget) - } - - @ViewBuilder - private func labeledIconCell(item: SystemAppSnapshot) -> some View { - if let img = loadIcon(for: item.bundleID) { - // Use launch-app to mirror non-debug launch behavior - Link(destination: URL(string: "stikjit://launch-app?bundle-id=\(item.bundleID)")!) { - VStack(spacing: 6) { - Image(uiImage: img) - .resizable() - .aspectRatio(1, contentMode: .fit) - .cornerRadius(12) - Text(item.displayName) - .font(.caption2) - .lineLimit(1) - .minimumScaleFactor(0.65) - .foregroundStyle(.primary) - } - .frame(maxWidth: .infinity) - } - } else { - placeholderCell() - .frame(maxWidth: .infinity) - } - } - - @ViewBuilder - private func placeholderCell() -> some View { - VStack(spacing: 6) { - ZStack { - RoundedRectangle(cornerRadius: 12) - .fill(Color(UIColor.systemGray5)) - Image(systemName: "plus") - .font(.system(size: 24, weight: .medium)) - .foregroundColor(.gray) - } - .aspectRatio(1, contentMode: .fit) - Text(" ") - .font(.caption2) - .opacity(0) // keep height consistent - } - } -} - -struct SystemAppsWidget: Widget { - let kind: String = "SystemAppsWidget" - - var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: SystemAppsProvider()) { entry in - SystemAppsWidgetEntryView(entry: entry) - } - .configurationDisplayName("Launch Shortcuts") - .description("Pin any app to launch directly from the widget.") - .supportedFamilies([.systemMedium]) - } -} - -// MARK: - Shared Helpers ----------------------------------------------------- - -private func loadIcon(for bundleID: String) -> UIImage? { - guard let container = FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: "group.com.stik.sj") - else { return nil } - let url = container - .appendingPathComponent("icons", isDirectory: true) - .appendingPathComponent("\(bundleID).png") - return UIImage(contentsOfFile: url.path) -} - -/// Fallback readable name from a bundle identifier -private func friendlyNameFromBundleID(_ bundleID: String) -> String { - 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 -} - diff --git a/DebugWidget/DebugWidgetBundle.swift b/DebugWidget/DebugWidgetBundle.swift deleted file mode 100644 index 77bf06ed..00000000 --- a/DebugWidget/DebugWidgetBundle.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// DebugWidgetBundle.swift -// DebugWidget -// -// Created by Stephen on 5/30/25. -// - -import WidgetKit -import SwiftUI - -@main -struct StikDebugWidgetBundle: WidgetBundle { - var body: some Widget { - // Both widgets enabled: Favorites uses enable-jit URL scheme (with PiP/script handled in-app), - // System Apps uses launch-app URL scheme for non-debug launch behavior. - FavoritesWidget() - SystemAppsWidget() - } -} diff --git a/DebugWidget/Info.plist b/DebugWidget/Info.plist deleted file mode 100644 index 0f118fb7..00000000 --- a/DebugWidget/Info.plist +++ /dev/null @@ -1,11 +0,0 @@ - - - - - NSExtension - - NSExtensionPointIdentifier - com.apple.widgetkit-extension - - - diff --git a/DebugWidgetExtension.entitlements b/DebugWidgetExtension.entitlements deleted file mode 100644 index 6e545182..00000000 --- a/DebugWidgetExtension.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.application-groups - - group.com.stik.stikdebug - - - diff --git a/README.md b/README.md index 56f4bbad..61b2ba46 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

StikDebug

-

An on-device debugger/JIT enabler for iOS versions 17.4+ powered by idevice.

+

An on-device debugger/JIT enabler for iOS versions 17.4+ powered by idevice.

@@ -15,34 +15,151 @@ - + - + + + +
-# Download +## Features +- **JIT:** Enable Just In Time coplation in sideloaded apps that have the `get-task-allow` entitlement. +- **App Launching:** Launch every app installed on your device. +- **Console:** Live app and system logs. +- **Scripts:** Manage automation scripts (mainly used for iOS 26 JIT). +- **Profiles:** Install/remove profile and see when apps will expire. +- **Device Info:** View detailed device metadata. +- **Processes:** Inspect running apps/processes and terminate them. +- **Location Simulator:** Simulate the GPS location of your device. +## Download > [!WARNING] > **Notice:** StikDebug is no longer available on the App Store. Please use the official download methods below.
- Add AltSource + Add AltSource - Download .ipa + Download .ipa
-## Project Status +## Compatibility + +| iOS Version | Status | Notes | +|--------------------------|----------------------|-----------------------------------------------------------------------| +| 1.0 – 17.3.X | Not supported | Uses Different Connection Protocols | +| 17.4 – 18.x | Fully supported | Stable | +| 26.0 – 26.3 | Supported | Limited App Availability (Developers need to update their apps to work.) | +| 26.4 Developer Beta 1 | Critical issues | lockdownd drops connections → JIT broken; do not update | +| iOS 26.4+ betas / future | Untested / partial | Check [Discord](https://discord.gg/ZnNcrRT3M8), [releases](https://github.com/StephenDev0/StikDebug/releases), or [@StephenDev0 on X](https://x.com/stephendev0) for updates | + +## How to Enable JIT + +StikDebug enables **JIT** for sideloaded apps on iOS 17.4+ without needing a computer after the initial pairing setup. + +### Requirements +- StikDebug installed (via AltSource, direct .ipa, or self-built) +- A valid **pairing file** (.plist / .mobiledevicepairing) for your device +- SideStore / AltStore / similar sideload tool (for app refreshing) +- A loopback vpn such as [LocalDevVPN](https://apps.apple.com/us/app/localdevvpn/id6755608044) + +### Steps +1. **Obtain a pairing file** + - Detailed guide: [Pairing File Instructions](https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md) (or ask in Discord). + +2. **Set up VPN** + - Launch LocalDevVPN and enable the vpn. + +4. **Enable JIT for an app** + - Launch StikDebug and press the `Enable JIT` button. + - Select your sideloaded app from the list in StikDebug. + +**Troubleshooting** +- "Connection dropped" or loopback errors → Check iOS version compatibility / beta warnings. +- Heartbeat erros → Ensure that the vpn is on and that you are connecected to Wi-Fi. +- Pairing file issues → Regenerate file with device unlocked & trusted. +- Still stuck? Join the [Discord](https://discord.gg/ZnNcrRT3M8) with logs/screenshots. + + + +## Building from Source > [!NOTE] -> A major code cleanup and rewrite is currently underway on the [`semi-rewrite`](https://github.com/StephenDev0/StikDebug/tree/semi-rewrite) branch to improve stability and maintainability. +> StikDebug is an open-source Xcode project written mostly in Swift. Building yourself lets you modify code, debug, or create custom versions. Requires a Mac with Xcode. + +### Requirements +- macOS (latest recommended) +- Xcode 16+ (Xcode 26+ preferred for iOS 26+ support) +- iOS device on iOS 17.4+ (for testing) +- Git +- Basic Xcode/Swift knowledge + +### Steps +1. **Clone the repo** + ```bash + git clone https://github.com/StephenDev0/StikDebug.git + cd StikDebug + ``` + +2. **Open in Xcode** + - Launch Xcode + - Open `StikDebug.xcodeproj` + +3. **Configure signing** + - Select the **StikDebug** target + - Go to **Signing & Capabilities** + - Sign in with your Apple ID (free or paid developer account) + - Set a unique **Bundle Identifier** (e.g., `com.yourname.StikDebug`) + +4. **Build & install** + - Select your connected device + - Press **Cmd + R** (or Product → Run) + - Trust the certificate on device: Settings → General → VPN & Device Management + +After install, follow the JIT setup steps above (pairing import, etc.). + +## Contributing + +Thank you for your interest in contributing to this project. Contributions of all kinds are welcome. + +### Reporting Bugs +If you discover a bug, please open an issue and include: +- A clear and descriptive title +- Steps to reproduce the issue +- Expected behavior vs. actual behavior +- Relevant logs, screenshots, or environment details (iOS version, device model, etc.) + +### Suggesting Features +To propose a new feature, open a feature request issue and provide: +- A clear description of the feature +- The problem it solves or the use case it addresses +- Any relevant examples or implementation ideas + +### Code Contributions (Best Practices) +- Follow normal Swift and SwiftUI style. +- Write clear and easy to understand code. +- Keep your changes consistent with how the project is already set up. +- Make sure everything builds and works without errors. + +We appreciate your time and effort in helping improve this project. ## Code Help [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/stephendev0/stikdebug) - -## License +## License StikDebug is licensed under **AGPL-3.0**. See [`LICENSE`](LICENSE) for details. diff --git a/StikDebug.xcodeproj/project.pbxproj b/StikDebug.xcodeproj/project.pbxproj index 27420903..91a458b5 100644 --- a/StikDebug.xcodeproj/project.pbxproj +++ b/StikDebug.xcodeproj/project.pbxproj @@ -7,24 +7,11 @@ objects = { /* Begin PBXBuildFile section */ - 17C744F02E20BED000834F17 /* Pipify in Frameworks */ = {isa = PBXBuildFile; productRef = 17C744EF2E20BED000834F17 /* Pipify */; }; 68D569BE2E1B415700A5BA36 /* CodeEditorView in Frameworks */ = {isa = PBXBuildFile; productRef = 68D569BD2E1B415700A5BA36 /* CodeEditorView */; }; 68D569C02E1B415700A5BA36 /* LanguageSupport in Frameworks */ = {isa = PBXBuildFile; productRef = 68D569BF2E1B415700A5BA36 /* LanguageSupport */; }; - 68E714E62E6AA2B00025610F /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 68E714E52E6AA2B00025610F /* ZIPFoundation */; }; - DC139F6E2DE97EA400F63846 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC139F6D2DE97EA400F63846 /* WidgetKit.framework */; }; - DC139F702DE97EA400F63846 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC139F6F2DE97EA400F63846 /* SwiftUI.framework */; }; - DC139F812DE97EA600F63846 /* DebugWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = DC139F6C2DE97EA400F63846 /* DebugWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - DCBA85862E3897BD00E88C06 /* StikImporter in Frameworks */ = {isa = PBXBuildFile; productRef = DCBA85852E3897BD00E88C06 /* StikImporter */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - DC139F7F2DE97EA600F63846 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = DC6F1D2F2D94EADD0071B2B6 /* Project object */; - proxyType = 1; - remoteGlobalIDString = DC139F6B2DE97EA400F63846; - remoteInfo = DebugWidgetExtension; - }; DC6F1D492D94EADF0071B2B6 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = DC6F1D2F2D94EADD0071B2B6 /* Project object */; @@ -48,7 +35,6 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( - DC139F812DE97EA600F63846 /* DebugWidgetExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -56,10 +42,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 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; }; - DC139F862DE97F2000F63846 /* DebugWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugWidgetExtension.entitlements; sourceTree = ""; }; DC6F1D372D94EADD0071B2B6 /* StikDebug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = StikDebug.app; sourceTree = BUILT_PRODUCTS_DIR; }; DC6F1D482D94EADF0071B2B6 /* StikDebugTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StikDebugTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DC6F1D522D94EADF0071B2B6 /* StikDebugUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StikDebugUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -73,24 +57,9 @@ ); target = DC6F1D362D94EADD0071B2B6 /* StikDebug */; }; - DC139F852DE97EA600F63846 /* Exceptions for "DebugWidget" folder in "DebugWidgetExtension" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Info.plist, - ); - target = DC139F6B2DE97EA400F63846 /* DebugWidgetExtension */; - }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - DC139F712DE97EA400F63846 /* DebugWidget */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - DC139F852DE97EA600F63846 /* Exceptions for "DebugWidget" folder in "DebugWidgetExtension" target */, - ); - path = DebugWidget; - sourceTree = ""; - }; DC6F1D392D94EADD0071B2B6 /* StikJIT */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -112,24 +81,12 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ - DC139F692DE97EA400F63846 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - DC139F702DE97EA400F63846 /* SwiftUI.framework in Frameworks */, - DC139F6E2DE97EA400F63846 /* WidgetKit.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; DC6F1D342D94EADD0071B2B6 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 17C744F02E20BED000834F17 /* Pipify in Frameworks */, - DCBA85862E3897BD00E88C06 /* StikImporter in Frameworks */, 68D569C02E1B415700A5BA36 /* LanguageSupport in Frameworks */, 68D569BE2E1B415700A5BA36 /* CodeEditorView in Frameworks */, - 68E714E62E6AA2B00025610F /* ZIPFoundation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -153,11 +110,9 @@ DC6F1D2E2D94EADD0071B2B6 = { isa = PBXGroup; children = ( - DC139F862DE97F2000F63846 /* DebugWidgetExtension.entitlements */, DC6F1D392D94EADD0071B2B6 /* StikJIT */, DC6F1D4B2D94EADF0071B2B6 /* StikJITTests */, DC6F1D552D94EADF0071B2B6 /* StikJITUITests */, - DC139F712DE97EA400F63846 /* DebugWidget */, DC6F1D752D94EB620071B2B6 /* Frameworks */, DC6F1D382D94EADD0071B2B6 /* Products */, ); @@ -169,7 +124,6 @@ DC6F1D372D94EADD0071B2B6 /* StikDebug.app */, DC6F1D482D94EADF0071B2B6 /* StikDebugTests.xctest */, DC6F1D522D94EADF0071B2B6 /* StikDebugUITests.xctest */, - DC139F6C2DE97EA400F63846 /* DebugWidgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -186,28 +140,6 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - DC139F6B2DE97EA400F63846 /* DebugWidgetExtension */ = { - isa = PBXNativeTarget; - buildConfigurationList = DC139F842DE97EA600F63846 /* Build configuration list for PBXNativeTarget "DebugWidgetExtension" */; - buildPhases = ( - DC139F682DE97EA400F63846 /* Sources */, - DC139F692DE97EA400F63846 /* Frameworks */, - DC139F6A2DE97EA400F63846 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - DC139F712DE97EA400F63846 /* DebugWidget */, - ); - name = DebugWidgetExtension; - packageProductDependencies = ( - ); - productName = DebugWidgetExtension; - productReference = DC139F6C2DE97EA400F63846 /* DebugWidgetExtension.appex */; - productType = "com.apple.product-type.app-extension"; - }; DC6F1D362D94EADD0071B2B6 /* StikDebug */ = { isa = PBXNativeTarget; buildConfigurationList = DC6F1D5C2D94EADF0071B2B6 /* Build configuration list for PBXNativeTarget "StikDebug" */; @@ -220,7 +152,6 @@ buildRules = ( ); dependencies = ( - DC139F802DE97EA600F63846 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( DC6F1D392D94EADD0071B2B6 /* StikJIT */, @@ -229,9 +160,6 @@ packageProductDependencies = ( 68D569BD2E1B415700A5BA36 /* CodeEditorView */, 68D569BF2E1B415700A5BA36 /* LanguageSupport */, - 17C744EF2E20BED000834F17 /* Pipify */, - DCBA85852E3897BD00E88C06 /* StikImporter */, - 68E714E52E6AA2B00025610F /* ZIPFoundation */, ); productName = StikJIT; productReference = DC6F1D372D94EADD0071B2B6 /* StikDebug.app */; @@ -293,9 +221,6 @@ LastSwiftUpdateCheck = 1620; LastUpgradeCheck = 1620; TargetAttributes = { - DC139F6B2DE97EA400F63846 = { - CreatedOnToolsVersion = 16.2; - }; DC6F1D362D94EADD0071B2B6 = { CreatedOnToolsVersion = 16.2; LastSwiftMigration = 1620; @@ -323,9 +248,6 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 68D569BC2E1B415700A5BA36 /* XCRemoteSwiftPackageReference "CodeEditorView" */, - 17C744EE2E20BED000834F17 /* XCRemoteSwiftPackageReference "swiftui-pipify" */, - DCBA85842E3897BD00E88C06 /* XCRemoteSwiftPackageReference "StikImporter" */, - 68E714E42E6AA2B00025610F /* XCRemoteSwiftPackageReference "ZIPFoundation" */, ); preferredProjectObjectVersion = 77; productRefGroup = DC6F1D382D94EADD0071B2B6 /* Products */; @@ -335,19 +257,11 @@ DC6F1D362D94EADD0071B2B6 /* StikDebug */, DC6F1D472D94EADF0071B2B6 /* StikDebugTests */, DC6F1D512D94EADF0071B2B6 /* StikDebugUITests */, - DC139F6B2DE97EA400F63846 /* DebugWidgetExtension */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - DC139F6A2DE97EA400F63846 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; DC6F1D352D94EADD0071B2B6 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -372,13 +286,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - DC139F682DE97EA400F63846 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; DC6F1D332D94EADD0071B2B6 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -403,11 +310,6 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - DC139F802DE97EA600F63846 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = DC139F6B2DE97EA400F63846 /* DebugWidgetExtension */; - targetProxy = DC139F7F2DE97EA600F63846 /* PBXContainerItemProxy */; - }; DC6F1D4A2D94EADF0071B2B6 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = DC6F1D362D94EADD0071B2B6 /* StikDebug */; @@ -421,67 +323,6 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ - DC139F822DE97EA600F63846 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; - CODE_SIGN_ENTITLEMENTS = DebugWidgetExtension.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 2743MYJ5UC; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = DebugWidget/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = DebugWidget; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug.DebugWidget; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - DC139F832DE97EA600F63846 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; - CODE_SIGN_ENTITLEMENTS = DebugWidgetExtension.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 2743MYJ5UC; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = DebugWidget/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = DebugWidget; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug.DebugWidget; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; DC6F1D5A2D94EADF0071B2B6 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -604,10 +445,10 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = StikJIT/StikJIT.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = "\"StikJIT/Preview Content\""; - DEVELOPMENT_TEAM = 2743MYJ5UC; + DEVELOPMENT_TEAM = XXK735HX49; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( @@ -619,6 +460,8 @@ INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "StikDebug needs access to devices on your local network so it can connect to the targets you add to the Device Library."; + INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "StikDebug uses location to stay running in the background and maintain your JIT connection."; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "StikDebug uses location to stay running in the background and maintain your JIT connection."; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -638,9 +481,10 @@ "$(PROJECT_DIR)/StikJIT/idevice", ); MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 2.3.7; + MARKETING_VERSION = 3.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -662,10 +506,10 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = StikJIT/StikJIT.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = "\"StikJIT/Preview Content\""; - DEVELOPMENT_TEAM = 2743MYJ5UC; + DEVELOPMENT_TEAM = XXK735HX49; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( @@ -677,6 +521,8 @@ INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "StikDebug needs access to devices on your local network so it can connect to the targets you add to the Device Library."; + INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "StikDebug uses location to stay running in the background and maintain your JIT connection."; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "StikDebug uses location to stay running in the background and maintain your JIT connection."; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -696,9 +542,10 @@ "$(PROJECT_DIR)/StikJIT/idevice", ); MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 2.3.7; + MARKETING_VERSION = 3.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -804,15 +651,6 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - DC139F842DE97EA600F63846 /* Build configuration list for PBXNativeTarget "DebugWidgetExtension" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - DC139F822DE97EA600F63846 /* Debug */, - DC139F832DE97EA600F63846 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; DC6F1D322D94EADD0071B2B6 /* Build configuration list for PBXProject "StikDebug" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -852,14 +690,6 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 17C744EE2E20BED000834F17 /* XCRemoteSwiftPackageReference "swiftui-pipify" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/hugeBlack/swiftui-pipify"; - requirement = { - branch = main; - kind = branch; - }; - }; 68D569BC2E1B415700A5BA36 /* XCRemoteSwiftPackageReference "CodeEditorView" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/mchakravarty/CodeEditorView"; @@ -868,30 +698,9 @@ minimumVersion = 0.15.4; }; }; - 68E714E42E6AA2B00025610F /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/weichsel/ZIPFoundation"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.9.19; - }; - }; - DCBA85842E3897BD00E88C06 /* XCRemoteSwiftPackageReference "StikImporter" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/StephenDev0/StikImporter"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.0.2; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 17C744EF2E20BED000834F17 /* Pipify */ = { - isa = XCSwiftPackageProductDependency; - package = 17C744EE2E20BED000834F17 /* XCRemoteSwiftPackageReference "swiftui-pipify" */; - productName = Pipify; - }; 68D569BD2E1B415700A5BA36 /* CodeEditorView */ = { isa = XCSwiftPackageProductDependency; package = 68D569BC2E1B415700A5BA36 /* XCRemoteSwiftPackageReference "CodeEditorView" */; @@ -902,16 +711,6 @@ package = 68D569BC2E1B415700A5BA36 /* XCRemoteSwiftPackageReference "CodeEditorView" */; productName = LanguageSupport; }; - 68E714E52E6AA2B00025610F /* ZIPFoundation */ = { - isa = XCSwiftPackageProductDependency; - package = 68E714E42E6AA2B00025610F /* XCRemoteSwiftPackageReference "ZIPFoundation" */; - productName = ZIPFoundation; - }; - DCBA85852E3897BD00E88C06 /* StikImporter */ = { - isa = XCSwiftPackageProductDependency; - package = DCBA85842E3897BD00E88C06 /* XCRemoteSwiftPackageReference "StikImporter" */; - productName = StikImporter; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = DC6F1D2F2D94EADD0071B2B6 /* Project object */; diff --git a/StikDebug.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/StikDebug.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7da08b05..7f82837d 100644 --- a/StikDebug.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/StikDebug.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ac6355f0d394d08e98cd8bc09669552151ca717b83a414b5708b301c9ba767e4", + "originHash" : "3675a8b363ad866505239c8e2c7f6a0862ed02618f2dca478b2519372fb79c0c", "pins" : [ { "identity" : "codeeditorview", @@ -18,33 +18,6 @@ "revision" : "5ff7f3363f7a08f77e0d761e38e6add31c2136e1", "version" : "1.8.1" } - }, - { - "identity" : "stikimporter", - "kind" : "remoteSourceControl", - "location" : "https://github.com/StephenDev0/StikImporter", - "state" : { - "revision" : "4d80fca1334493889501d5b5a9fdcbb262e11f24", - "version" : "1.0.2" - } - }, - { - "identity" : "swiftui-pipify", - "kind" : "remoteSourceControl", - "location" : "https://github.com/hugeBlack/swiftui-pipify", - "state" : { - "branch" : "main", - "revision" : "a1ec2fd1781c8289bff1a8b3f664dcf21c910efb" - } - }, - { - "identity" : "zipfoundation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/weichsel/ZIPFoundation", - "state" : { - "revision" : "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0", - "version" : "0.9.19" - } } ], "version" : 3 diff --git a/StikDebug.xcodeproj/xcshareddata/xcschemes/StikDebug.xcscheme b/StikDebug.xcodeproj/xcshareddata/xcschemes/StikDebug.xcscheme index 0b70022a..cf047ee7 100644 --- a/StikDebug.xcodeproj/xcshareddata/xcschemes/StikDebug.xcscheme +++ b/StikDebug.xcodeproj/xcshareddata/xcschemes/StikDebug.xcscheme @@ -74,9 +74,6 @@ ReferencedContainer = "container:StikDebug.xcodeproj"> - - UIBackgroundModes audio + location + fetch UIFileSharingEnabled diff --git a/StikJIT/JSSupport/RunJSView.swift b/StikJIT/JSSupport/RunJSView.swift index 8e994e7c..f9b37d25 100644 --- a/StikJIT/JSSupport/RunJSView.swift +++ b/StikJIT/JSSupport/RunJSView.swift @@ -89,7 +89,7 @@ class RunJSViewModel: ObservableObject { self.logs.append(exception.debugDescription) } self.logs.append("Script Execution Completed") - self.logs.append("You are safe to close the PIP Window.") + self.logs.append("You are safe to close this window.") } } @@ -203,28 +203,6 @@ class RunJSViewModel: ObservableObject { } } -struct RunJSViewPiP: View { - @Binding var model: RunJSViewModel? - @State private var logs: [String] = [] - private let timer = Timer.publish(every: 0.034, on: .main, in: .common).autoconnect() - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - ForEach(logs.suffix(6).indices, id: \.self) { index in - Text(logs.suffix(6)[index]) - .font(.system(size: 12)) - .foregroundStyle(.white) - } - } - .padding() - .onReceive(timer) { _ in - logs = model?.logs ?? [] - } - .frame(width: 300, height: 150) - } -} - - struct RunJSView: View { @ObservedObject var model: RunJSViewModel diff --git a/StikJIT/JSSupport/ScriptEditorView.swift b/StikJIT/JSSupport/ScriptEditorView.swift index f1ca2aca..606a4de5 100644 --- a/StikJIT/JSSupport/ScriptEditorView.swift +++ b/StikJIT/JSSupport/ScriptEditorView.swift @@ -16,20 +16,15 @@ struct ScriptEditorView: View { @State private var position: CodeEditor.Position = .init() @State private var messages: Set> = [] - @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 { ZStack { - ThemedBackground(style: backgroundStyle) + Color(UIColor.systemBackground) .ignoresSafeArea() VStack(spacing: 0) { @@ -37,7 +32,7 @@ struct ScriptEditorView: View { text: $scriptContent, position: $position, messages: $messages, - language: .swift() + language: .none ) .font(.system(.footnote, design: .monospaced)) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -57,8 +52,7 @@ struct ScriptEditorView: View { } } } - .preferredColorScheme(preferredScheme) - .tint(colorScheme == .dark ? .white : .black) + .tint(colorScheme == .dark ? .white : .black) .toolbar(.hidden, for: .tabBar) } diff --git a/StikJIT/JSSupport/ScriptListView.swift b/StikJIT/JSSupport/ScriptListView.swift index e4388b71..cb1adbb8 100644 --- a/StikJIT/JSSupport/ScriptListView.swift +++ b/StikJIT/JSSupport/ScriptListView.swift @@ -35,73 +35,75 @@ struct ScriptListView: View { guard !searchText.isEmpty else { return scripts } return scripts.filter { $0.lastPathComponent.localizedCaseInsensitiveContains(searchText) } } - - @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue - @Environment(\.themeExpansionManager) private var themeExpansion - @Environment(\.colorScheme) private var colorScheme - 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: backgroundStyle) - .ignoresSafeArea() - - ScrollView { - VStack(spacing: 20) { - headerCard - - if filteredScripts.isEmpty { - emptyCard - } else { - ForEach(filteredScripts, id: \.self) { script in - scriptRow(script) - } + List { + if isPickerMode { + Section { + Button { + onSelectScript?(nil) + } label: { + Label("No Script", systemImage: "nosign") } } - .padding(.horizontal, 20) - .padding(.vertical, 30) - } - - if isBusy { - Color.black.opacity(0.35).ignoresSafeArea() - ProgressView("Working…") - .padding(16) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) - ) - ) - .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) } - if alertVisible { - CustomErrorView(title: alertTitle, - message: alertMessage, - onDismiss: { alertVisible = false }, - messageType: alertIsSuccess ? .success : .error) - } - - if justCopied { - VStack { - Spacer() - Text("Copied") - .font(.footnote.weight(.semibold)) - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(.ultraThinMaterial, in: Capsule()) - .overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 1)) - .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 3) - .transition(.move(edge: .bottom).combined(with: .opacity)) - .padding(.bottom, 30) + if filteredScripts.isEmpty { + Section { + VStack(alignment: .leading, spacing: 4) { + Label( + isPickerMode ? "No scripts available" : "No scripts found", + systemImage: "doc.text.magnifyingglass" + ) + .foregroundStyle(.secondary) + Text(isPickerMode ? "Import a file or choose None." : "Tap New or Import to get started.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + } else { + Section { + ForEach(filteredScripts, id: \.self) { script in + scriptRow(script) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + if !isPickerMode { + Button(role: .destructive) { + pendingDelete = script + showDeleteConfirmation = true + } label: { Label("Delete", systemImage: "trash") } + } + } + .contextMenu { + Button { copyName(script) } label: { + Label("Copy Filename", systemImage: "doc.on.doc") + } + Button { copyPath(script) } label: { + Label("Copy Path", systemImage: "folder") + } + if !isPickerMode { + Button { saveDefaultScript(script) } label: { + Label("Set Default", systemImage: "star") + } + Divider() + Button(role: .destructive) { + pendingDelete = script + showDeleteConfirmation = true + } label: { Label("Delete", systemImage: "trash") } + } + } + } } - .animation(.easeInOut(duration: 0.25), value: justCopied) } } + .listStyle(.insetGrouped) + .searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search scripts…" + ) .navigationTitle(isPickerMode ? "Choose Script" : "Scripts") .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { @@ -125,7 +127,7 @@ struct ScriptListView: View { Button("Delete", role: .destructive) { deleteScript(script) } Button("Cancel", role: .cancel) { pendingDelete = nil } } message: { script in - Text("Are you sure you want to delete \(script.lastPathComponent)? This cannot be undone.") + Text("Delete \(script.lastPathComponent)? This cannot be undone.") } .fileImporter( isPresented: $showImporter, @@ -137,163 +139,65 @@ struct ScriptListView: View { } } } - .preferredColorScheme(preferredScheme) - } - - // MARK: - Cards - - private var headerCard: some View { - VStack(spacing: 12) { - TextField("Search scripts…", text: $searchText) - .padding(12) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(Color.white.opacity(0.12), lineWidth: 1) - ) - - HStack(spacing: 12) { - if isPickerMode { - WideGlassyButton(title: "None", systemImage: "nosign") { - onSelectScript?(nil) - } - WideGlassyButton(title: "Import", systemImage: "tray.and.arrow.down") { - showImporter = true - } - } else { - WideGlassyButton(title: "New", systemImage: "doc.badge.plus") { - showNewFileAlert = true - } - WideGlassyButton(title: "Import", systemImage: "tray.and.arrow.down") { - showImporter = true - } + .overlay { + if isBusy { + Color.black.opacity(0.35).ignoresSafeArea() + ProgressView("Working…") + .padding(16) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + if justCopied { + VStack { + Spacer() + Text("Copied") + .font(.footnote.weight(.semibold)) + .padding(.horizontal, 14).padding(.vertical, 10) + .background(.ultraThinMaterial, in: Capsule()) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .padding(.bottom, 30) } + .animation(.easeInOut(duration: 0.25), value: justCopied) } } - .padding(20) - .background(glassyBackground) + .alert(alertTitle, isPresented: $alertVisible) { + Button("OK", role: .cancel) { } + } message: { + Text(alertMessage) + } } + // MARK: - Row + @ViewBuilder private func scriptRow(_ script: URL) -> some View { + let isDefault = defaultScriptName == script.lastPathComponent if isPickerMode { Button { onSelectScript?(script) } label: { - scriptCard(script, showDefaultStar: true, showDelete: false) + HStack { + Label(script.lastPathComponent, systemImage: "doc.text.fill") + Spacer() + if isDefault { + Image(systemName: "star.fill").foregroundStyle(.yellow).imageScale(.small) + } + } } - .buttonStyle(.plain) } else { NavigationLink { ScriptEditorView(scriptURL: script) } label: { - scriptCard(script, showDefaultStar: true, showDelete: true) - } - .buttonStyle(.plain) - } - } - - private func scriptCard(_ script: URL, showDefaultStar: Bool, showDelete: Bool) -> some View { - let isDefault = defaultScriptName == script.lastPathComponent - - return HStack(spacing: 12) { - Image(systemName: "doc.text.fill") - .foregroundColor(.blue) - .imageScale(.large) - - Text(script.lastPathComponent) - .font(.body.weight(.medium)) - .lineLimit(1) - - Spacer() - - if showDefaultStar, isDefault { - Image(systemName: "star.fill") - .foregroundColor(.yellow) - } - - if showDelete { - Button(role: .destructive) { - pendingDelete = script - showDeleteConfirmation = true - } label: { - Image(systemName: "trash") - .foregroundColor(.red) - } - .buttonStyle(.borderless) - } - } - .padding(20) - .background(glassyBackground) - .contextMenu { - Button { copyName(script) } label: { - Label("Copy Filename", systemImage: "doc.on.doc") - } - Button { copyPath(script) } label: { - Label("Copy Path", systemImage: "folder") - } - if !isPickerMode { - Button { saveDefaultScript(script) } label: { - Label("Set Default", systemImage: "star") + HStack { + Label(script.lastPathComponent, systemImage: "doc.text.fill") + Spacer() + if isDefault { + Image(systemName: "star.fill").foregroundStyle(.yellow).imageScale(.small) + } } } } } - private var emptyCard: some View { - VStack(spacing: 6) { - Label(isPickerMode ? "No scripts available" : "No scripts found", - systemImage: "doc.text.magnifyingglass") - .font(.subheadline.weight(.semibold)) - Text(isPickerMode ? "Import a file or choose None." : "Tap New or Import to get started.") - .font(.footnote) - .foregroundColor(.secondary) - } - .padding(40) - .frame(maxWidth: .infinity) - .background(glassyBackground) - } - - private var glassyBackground: some View { - 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 - case .adaptiveGradient(let light, let dark): - colors = colorScheme == .dark ? dark : light - } - 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 private func scriptsDirectory() -> URL { @@ -325,7 +229,6 @@ struct ScriptListView: View { ("manic", "manic.js"), ("UTM-Dolphin", "UTM-Dolphin.js") ] - for entry in bundledScripts { if let bundleURL = Bundle.main.url(forResource: entry.resource, withExtension: "js") { let destination = directory.appendingPathComponent(entry.filename) @@ -409,7 +312,7 @@ struct ScriptListView: View { } } - // MARK: - Feedback helpers + // MARK: - Feedback private func presentError(title: String, message: String) { alertTitle = title; alertMessage = message @@ -439,40 +342,7 @@ struct ScriptListView: View { } } -// MARK: - Equal-width rounded-rectangle button (centered content) -struct WideGlassyButton: View { - let title: String - let systemImage: String - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack(spacing: 8) { - Image(systemName: systemImage) - .imageScale(.medium) - .font(.body.weight(.semibold)) - Text(title) - .font(.body.weight(.semibold)) - .lineLimit(1) - .minimumScaleFactor(0.8) - } - .frame(maxWidth: .infinity, alignment: .center) - .padding(.horizontal, 14) - } - .frame(height: 44) - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(.ultraThinMaterial) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(Color.white.opacity(0.12), lineWidth: 1) - ) - .contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .buttonStyle(.plain) - } -} +// MARK: - Script content stubs private let screenshotDemoScript = """ // Screenshot Demo Script diff --git a/StikJIT/Preview Content/Preview Assets.xcassets/Contents.json b/StikJIT/Preview Content/Preview Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596..00000000 --- a/StikJIT/Preview Content/Preview Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/StikJIT/Amethyst-MeloNX.js b/StikJIT/Scripts/Amethyst-MeloNX.js similarity index 100% rename from StikJIT/Amethyst-MeloNX.js rename to StikJIT/Scripts/Amethyst-MeloNX.js diff --git a/StikJIT/Geode.js b/StikJIT/Scripts/Geode.js similarity index 100% rename from StikJIT/Geode.js rename to StikJIT/Scripts/Geode.js diff --git a/StikJIT/UTM-Dolphin.js b/StikJIT/Scripts/UTM-Dolphin.js similarity index 100% rename from StikJIT/UTM-Dolphin.js rename to StikJIT/Scripts/UTM-Dolphin.js diff --git a/StikJIT/JSSupport/attachDetach.js b/StikJIT/Scripts/attachDetach.js similarity index 100% rename from StikJIT/JSSupport/attachDetach.js rename to StikJIT/Scripts/attachDetach.js diff --git a/StikJIT/maciOS.js b/StikJIT/Scripts/maciOS.js similarity index 100% rename from StikJIT/maciOS.js rename to StikJIT/Scripts/maciOS.js diff --git a/StikJIT/manic.js b/StikJIT/Scripts/manic.js similarity index 100% rename from StikJIT/manic.js rename to StikJIT/Scripts/manic.js diff --git a/StikJIT/StikJIT-Bridging-Header.h b/StikJIT/StikJIT-Bridging-Header.h index c4b0c21b..dfaf4557 100644 --- a/StikJIT/StikJIT-Bridging-Header.h +++ b/StikJIT/StikJIT-Bridging-Header.h @@ -8,6 +8,5 @@ #include "idevice/heartbeat.h" #include "JSSupport/JSSupport.h" #include "idevice/ideviceinfo.h" -#include "idevice/ls.h" +#include "idevice/location_simulation.h" #include "idevice/profiles.h" -#include "idevice/something.h" diff --git a/StikJIT/StikJITApp.swift b/StikJIT/StikJITApp.swift index 1b0f31a5..73703d60 100644 --- a/StikJIT/StikJITApp.swift +++ b/StikJIT/StikJITApp.swift @@ -15,159 +15,9 @@ private func registerAdvancedOptionsDefault() { // Enable advanced options by default on iOS 19/26 and above let enabled = os.majorVersion >= 19 UserDefaults.standard.register(defaults: ["enableAdvancedOptions": enabled]) - UserDefaults.standard.register(defaults: ["enablePiP": enabled]) UserDefaults.standard.register(defaults: [UserDefaults.Keys.txmOverride: false]) -} - -// MARK: - Welcome Sheet - -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 { - themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue - } - - var body: some View { - ZStack { - // Background now comes from global BackgroundContainer - Color.clear.ignoresSafeArea() - - ScrollView { - VStack(spacing: 20) { - // Card container with glassy material and stroke - VStack(alignment: .leading, spacing: 16) { - // Title - Text("Welcome!") - .font(.system(.largeTitle, design: .rounded).weight(.bold)) - .foregroundColor(.primary) - .padding(.top, 8) - - // Intro - Text("Thanks for installing the app. This brief introduction will help you get started.") - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.leading) - - // App description - VStack(alignment: .leading, spacing: 6) { - Label("On‑device debugger", systemImage: "bolt.shield.fill") - .foregroundColor(accent) - .font(.headline) - Text("StikDebug is an on‑device debugger designed specifically for self‑developed apps. It helps streamline testing and troubleshooting without sending any data to external servers.") - .font(.callout) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - // Continue button - Button(action: { onDismiss?() }) { - Text("Continue") - .font(.system(size: 16, weight: .semibold, design: .rounded)) - .foregroundColor(accent.contrastText()) - .frame(height: 44) - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(accent) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color.primary.opacity(0.15), lineWidth: 1) - ) - } - .padding(.top, 8) - .accessibilityIdentifier("welcome_continue_button") - } - .padding(20) - .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .strokeBorder(Color.primary.opacity(0.15), lineWidth: 1) - ) - ) - .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) - .shadow(color: .black.opacity(colorScheme == .dark ? 0.15 : 0.08), radius: 12, x: 0, y: 4) - - // Footer version info for consistency - HStack { - Spacer() - Text("iOS \(UIDevice.current.systemVersion)") - .font(.footnote) - .foregroundColor(.secondary) - Spacer() - } - .padding(.top, 6) - } - .padding(.horizontal, 20) - .padding(.vertical, 30) - } - } - // Inherit preferredColorScheme from BackgroundContainer (no local override) - } -} - -// MARK: - AccentColor Environment Key (leave available but unused) - -struct AccentColorKey: EnvironmentKey { - static let defaultValue: Color = .accentColor -} - -extension EnvironmentValues { - var accentColor: Color { - get { self[AccentColorKey.self] } - set { self[AccentColorKey.self] = newValue } - } -} - -// MARK: - Helper Functions and Globals - -let fileManager = FileManager.default - -func httpGet(_ urlString: String, result: @escaping (String?) -> Void) { - if let url = URL(string: urlString) { - let task = URLSession.shared.dataTask(with: url) { data, response, error in - if let error = error { - print("Error: \(error.localizedDescription)") - result(nil) - return - } - - if let data = data, let httpResponse = response as? HTTPURLResponse { - if httpResponse.statusCode == 200 { - print("Response: \(httpResponse.statusCode)") - if let dataString = String(data: data, encoding: .utf8) { - result(dataString) - } - } else { - print("Received non-200 status code: \(httpResponse.statusCode)") - } - } - } - task.resume() - } -} - -func UpdateRetrieval() -> Bool { - var ver: String { - let marketingVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" - return marketingVersion - } - let urlString = "https://raw.githubusercontent.com/0-Blu/StikJIT/refs/heads/main/version.txt" - var res = false - httpGet(urlString) { result in - if let fc = result { - if ver != fc { - res = true - } - } - } - return res + UserDefaults.standard.register(defaults: ["keepAliveAudio": true]) + UserDefaults.standard.register(defaults: ["keepAliveLocation": true]) } // MARK: - DNS Checker @@ -202,19 +52,15 @@ class DNSChecker: ObservableObject { group.notify(queue: .main) { if self.controlIP == nil { self.dnsError = "No internet connection." - print("Control host lookup failed, so no internet connection.") } else if self.appleIP == nil { self.dnsError = "Apple DNS blocked. Your network might be filtering Apple traffic." - print("Control lookup succeeded, but Apple lookup failed: likely blocked.") } else { self.dnsError = nil - print("DNS lookups succeeded: Apple -> \(self.appleIP!), Control -> \(self.controlIP!)") } } } else { DispatchQueue.main.async { self.dnsError = nil - print("Not connected to WiFi; continuing without DNS check.") } } } @@ -279,56 +125,23 @@ private var heartbeatPendingShowUI = true @main struct HeartbeatApp: App { - @AppStorage("hasLaunchedBefore") var hasLaunchedBefore: Bool = false - @AppStorage("customAccentColor") private var customAccentColorHex: String = "" - @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue - @State private var showWelcomeSheet: Bool = false - @State private var show_alert = false - @State private var alert_string = "" - @State private var alert_title = "" @StateObject private var mount = MountingProgress.shared - @StateObject private var themeExpansionManager = ThemeExpansionManager() @Environment(\.scenePhase) private var scenePhase // Observe scene lifecycle @State private var shouldAttemptHeartbeatRestart = false init() { registerAdvancedOptionsDefault() - newVerCheck() - let fixMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, #selector(UIDocumentPickerViewController.fix_init(forOpeningContentTypes:asCopy:)))! - let origMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, #selector(UIDocumentPickerViewController.init(forOpeningContentTypes:asCopy:)))! - method_exchangeImplementations(origMethod, fixMethod) - - // 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 updateUIKitTint(customHex: String, hasAccess: Bool) { - let color: UIColor - if hasAccess, !customHex.isEmpty, let swiftColor = Color(hex: customHex) { - color = UIColor(swiftColor) - } else { - color = .systemBlue + if UserDefaults.standard.bool(forKey: "keepAliveAudio") { + BackgroundAudioManager.shared.start() } - UIView.appearance().tintColor = color - } - - func newVerCheck() { - let currentDate = Calendar.current.startOfDay(for: Date()) - let VUA = UserDefaults.standard.object(forKey: "VersionUpdateAlert") as? Date ?? Date.distantPast - - if currentDate > Calendar.current.startOfDay(for: VUA) { - if UpdateRetrieval() { - alert_title = "Update Avaliable!" - let urlString = "https://raw.githubusercontent.com/0-Blu/StikJIT/refs/heads/main/version.txt" - httpGet(urlString) { result in - if result == nil { return } - alert_string = "Update to: version \(result!)!" - show_alert = true - } - } - UserDefaults.standard.set(currentDate, forKey: "VersionUpdateAlert") + if UserDefaults.standard.bool(forKey: "keepAliveLocation") { + BackgroundLocationManager.shared.start() + } + if let fixMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, #selector(UIDocumentPickerViewController.fix_init(forOpeningContentTypes:asCopy:))), + let origMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, #selector(UIDocumentPickerViewController.init(forOpeningContentTypes:asCopy:))) { + method_exchangeImplementations(origMethod, fixMethod) } + } private func handleScenePhaseChange(_ newPhase: ScenePhase) { @@ -345,77 +158,31 @@ struct HeartbeatApp: App { } } - private var globalAccent: Color { - themeExpansionManager.resolvedAccentColor(from: customAccentColorHex) - } - var body: some Scene { WindowGroup { - BackgroundContainer { - MainTabView() - .onAppear { - Task { - let fileManager = FileManager.default - for item in ddiDownloadItems { - let destinationURL = URL.documentsDirectory.appendingPathComponent(item.relativePath) - if fileManager.fileExists(atPath: destinationURL.path) { continue } - do { - try await downloadFile(from: item.urlString, to: destinationURL) - } catch { - await MainActor.run { - alert_title = "An Error has Occurred" - alert_string = "[Download DDI Error]: \(error.localizedDescription)" - show_alert = true - } - break + MainTabView() + .onAppear { + Task { + let fileManager = FileManager.default + for item in ddiDownloadItems { + let destinationURL = URL.documentsDirectory.appendingPathComponent(item.relativePath) + if fileManager.fileExists(atPath: destinationURL.path) { continue } + do { + try await downloadFile(from: item.urlString, to: destinationURL) + } catch { + await MainActor.run { + showAlert(title: "An Error has Occurred", + message: "[Download DDI Error]: \(error.localizedDescription)", + showOk: true) } + break } } } - .overlay( - ZStack { - if show_alert { - CustomErrorView( - title: alert_title, - message: alert_string, - onDismiss: { - show_alert = false - }, - showButton: true, - primaryButtonText: "OK" - ) - } - } - ) - } - .themeExpansionManager(themeExpansionManager) - // Apply global tint to all SwiftUI views in this window - .tint(globalAccent) - .onAppear { - // On first launch, present the welcome sheet. - if !hasLaunchedBefore { - showWelcomeSheet = true } - 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) - } .onChange(of: scenePhase) { newPhase in handleScenePhaseChange(newPhase) } - .sheet(isPresented: $showWelcomeSheet) { - WelcomeSheetView { - // When the user taps "Continue", mark the app as launched. - hasLaunchedBefore = true - showWelcomeSheet = false - } - } } } @@ -455,7 +222,6 @@ class MountingProgress: ObservableObject { func progressCallback(progress: size_t, total: size_t, context: UnsafeMutableRawPointer?) { let percentage = Double(progress) / Double(total) * 100.0 - print("Mounting progress: \(percentage)%") DispatchQueue.main.async { self.mountProgress = percentage } @@ -477,20 +243,18 @@ class MountingProgress: ObservableObject { self.mountingThread = nil } - mountingThread = Thread { [weak self] in + let thread = Thread { [weak self] in guard let self = self else { return } let mountResult = mountPersonalDDI( imagePath: URL.documentsDirectory.appendingPathComponent("DDI/Image.dmg").path, trustcachePath: URL.documentsDirectory.appendingPathComponent("DDI/Image.dmg.trustcache").path, manifestPath: URL.documentsDirectory.appendingPathComponent("DDI/BuildManifest.plist").path, ) - + DispatchQueue.main.async { if mountResult != 0 { - showAlert(title: "Error", message: "An Error Occured when Mounting the DDI\nError Code: \(mountResult)", showOk: true, showTryAgain: true) { shouldTryAgain in - if shouldTryAgain { - self.mount() - } + showAlert(title: "Error", message: "An Error Occurred when Mounting the DDI\nError Code: \(mountResult)", showOk: true, showTryAgain: true) { shouldTryAgain in + if shouldTryAgain { self.mount() } } } else { self.coolisMounted = true @@ -499,10 +263,10 @@ class MountingProgress: ObservableObject { self.mountingThread = nil } } - - mountingThread!.qualityOfService = .background - mountingThread!.name = "mounting" - mountingThread!.start() + thread.qualityOfService = .background + thread.name = "mounting" + thread.start() + mountingThread = thread } } } @@ -511,13 +275,7 @@ func isPairing() -> Bool { let pairingpath = URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path var pairingFile: IdevicePairingFile? let err = idevice_pairing_file_read(pairingpath, &pairingFile) - if let err { - print("Failed to read pairing file: \(err.pointee.code)") - if err.pointee.code == -9 { // InvalidHostID is -9 - return false - } - return false - } + if err != nil { return false } idevice_pairing_file_free(pairingFile) return true } @@ -548,7 +306,7 @@ func startHeartbeatInBackground(showErrorUI: Bool = true) { } do { try JITEnableContext.shared.startHeartbeat() - print("Heartbeat started successfully") + LogManager.shared.addInfoLog("Heartbeat started successfully") pubHeartBeat = true DispatchQueue.main.async { @@ -561,15 +319,15 @@ func startHeartbeatInBackground(showErrorUI: Bool = true) { } catch { let err2 = error as NSError let code = err2.code - print("Error: \(error.localizedDescription) (Code: \(code))") + LogManager.shared.addErrorLog("\(error.localizedDescription) (Code: \(code))") guard showErrorUI else { return } DispatchQueue.main.async { if code == -9 { do { try FileManager.default.removeItem(at: URL.documentsDirectory.appendingPathComponent("pairingFile.plist")) - print("Removed invalid pairing file") + LogManager.shared.addInfoLog("Removed invalid pairing file") } catch { - print("Error removing invalid pairing file: \(error)") + LogManager.shared.addErrorLog("Failed to remove invalid pairing file: \(error.localizedDescription)") } showAlert( @@ -647,70 +405,37 @@ func checkDeviceConnection(callback: @escaping (Bool, String?) -> Void) { } } -public func showAlert(title: String, message: String, showOk: Bool, showTryAgain: Bool = false, primaryButtonText: String? = nil, messageType: MessageType = .error, completion: ((Bool) -> Void)? = nil) { +public func showAlert(title: String, message: String, showOk: Bool, showTryAgain: Bool = false, primaryButtonText: String? = nil, completion: ((Bool) -> Void)? = nil) { DispatchQueue.main.async { - guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { + guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootViewController = scene.windows.first?.rootViewController else { return } - let rootViewController = scene.windows.first?.rootViewController + + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + if showTryAgain { - let customErrorView = CustomErrorView( - title: title, - message: message, - onDismiss: { - rootViewController?.presentedViewController?.dismiss(animated: true) - completion?(false) - }, - showButton: true, - primaryButtonText: primaryButtonText ?? "Try Again", - onPrimaryButtonTap: { - completion?(true) - }, - messageType: messageType - ) - let hostingController = UIHostingController(rootView: customErrorView) - hostingController.modalPresentationStyle = .overFullScreen - hostingController.modalTransitionStyle = .crossDissolve - hostingController.view.backgroundColor = .clear - rootViewController?.present(hostingController, animated: true) + alert.addAction(UIAlertAction(title: primaryButtonText ?? "Try Again", style: .default) { _ in + completion?(true) + }) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in + completion?(false) + }) } else if showOk { - let customErrorView = CustomErrorView( - title: title, - message: message, - onDismiss: { - rootViewController?.presentedViewController?.dismiss(animated: true) - completion?(true) - }, - showButton: true, - primaryButtonText: primaryButtonText ?? "OK", - onPrimaryButtonTap: { - rootViewController?.presentedViewController?.dismiss(animated: true) - completion?(true) - }, - messageType: messageType - ) - let hostingController = UIHostingController(rootView: customErrorView) - hostingController.modalPresentationStyle = .overFullScreen - hostingController.modalTransitionStyle = .crossDissolve - hostingController.view.backgroundColor = .clear - rootViewController?.present(hostingController, animated: true) + alert.addAction(UIAlertAction(title: primaryButtonText ?? "OK", style: .default) { _ in + completion?(true) + }) } else { - let customErrorView = CustomErrorView( - title: title, - message: message, - onDismiss: { - rootViewController?.presentedViewController?.dismiss(animated: true) - completion?(false) - }, - showButton: false, - messageType: messageType - ) - let hostingController = UIHostingController(rootView: customErrorView) - hostingController.modalPresentationStyle = .overFullScreen - hostingController.modalTransitionStyle = .crossDissolve - hostingController.view.backgroundColor = .clear - rootViewController?.present(hostingController, animated: true) + alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in + completion?(true) + }) + } + + var topController = rootViewController + while let presented = topController.presentedViewController { + topController = presented } + topController.present(alert, animated: true) } } diff --git a/StikJIT/Utilities/AppTheme.swift b/StikJIT/Utilities/AppTheme.swift deleted file mode 100644 index 76162c98..00000000 --- a/StikJIT/Utilities/AppTheme.swift +++ /dev/null @@ -1,411 +0,0 @@ -// -// AppTheme.swift -// StikJIT -// -// Created by Assistant on 9/12/25. -// - -import SwiftUI - -// MARK: - AppTheme model - -enum AppTheme: String, CaseIterable, Identifiable { - 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 "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" - } - } - - var preferredColorScheme: ColorScheme? { - switch self { - case .system: - return nil - case .sunset, .forest: - return nil - case .darkStatic, .neonAnimated, .blobs, .particles, .aurora, .ocean, .midnight, .cyberwave: - return .dark - } - } - - var backgroundStyle: BackgroundStyle { - switch self { - case .system: - return .adaptiveGradient(light: Palette.systemLightGradient, - dark: Palette.systemDarkGradient) - 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: 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]) - case adaptiveGradient(light: [Color], dark: [Color]) -} - -private struct Palette { - static let defaultGradient = hexColors("#1C1F3A", "#3E4C7C", "#1F1C2C") - static let systemLightGradient = hexColors("#F6F8FF", "#E3ECFF", "#F0F4FF") - static let systemDarkGradient = obsidianGradient - 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: colors.ensureMinimumCount()), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) -} - -struct ThemedBackground: View { - let style: BackgroundStyle - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.colorScheme) private var colorScheme - - private var identity: String { - style.identityKey - } - - var body: some View { - Group { - switch style { - case .staticGradient(let colors): - staticGradientView(colors: colors) - .ignoresSafeArea() - case .animatedGradient(let colors, let speed): - if reduceMotion { - staticGradientView(colors: colors) - .ignoresSafeArea() - } else { - AnimatedGradientBackground(colors: colors, speed: speed) - } - case .blobs(let colors, let background): - if reduceMotion { - staticGradientView(colors: background.isEmpty ? colors : background) - .ignoresSafeArea() - } else { - BlobBackground(blobColors: colors.ensureMinimumCount(), backgroundColors: background.ensureMinimumCount()) - } - case .particles(let particle, let background): - if reduceMotion { - staticGradientView(colors: background.ensureMinimumCount()) - .ignoresSafeArea() - } else { - ParticleFieldBackground(particleColor: particle, backgroundColors: background.ensureMinimumCount()) - } - case .customGradient(let colors): - staticGradientView(colors: colors) - .ignoresSafeArea() - case .adaptiveGradient(let light, let dark): - let colors = colorScheme == .dark ? dark : light - staticGradientView(colors: colors) - .ignoresSafeArea() - } - } - .id(identity) - } -} - -// MARK: - Background container to use at app root - -struct BackgroundContainer: 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 backgroundStyle: BackgroundStyle { - themeExpansion?.backgroundStyle(for: rawTheme) ?? AppTheme.system.backgroundStyle - } - - private var preferredScheme: ColorScheme? { - themeExpansion?.preferredColorScheme(for: rawTheme) - } - - var body: some View { - ZStack { - ThemedBackground(style: backgroundStyle) - .ignoresSafeArea() - content - } - .preferredColorScheme(preferredScheme) - } -} - -// MARK: - Animated backgrounds - -private struct AnimatedGradientBackground: View { - let colors: [Color] - let speed: Double - - var body: some View { - TimelineView(.animation) { timeline in - let now = timeline.date.timeIntervalSinceReferenceDate - let phase = now * speed - let rotation = Angle(degrees: phase.truncatingRemainder(dividingBy: 360) * 45) - let gradientColors = colors.ensureMinimumCount() - - Rectangle() - .fill(.ultraThinMaterial) - .ignoresSafeArea(edges: .top) - .ignoresSafeArea(edges: .bottom) - .background(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) - .ignoresSafeArea() - )) - } - } -} - -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 blobs = max(blobColors.count, 4) - let radius = min(size.width, size.height) * 0.45 - - for i in 0.. geo.size.width { p.position.x = 0 } - if p.position.y < 0 { p.position.y = geo.size.height } - if p.position.y > geo.size.height { p.position.y = 0 } - - next[i] = p - } - particles = next - } - } - } - } - .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 - } - - var identityKey: String { - map { $0.identityKey }.joined(separator: ",") - } -} - -private extension BackgroundStyle { - var identityKey: String { - switch self { - case .staticGradient(let colors): - return "static:\(colors.identityKey)" - case .animatedGradient(let colors, let speed): - return "animated:\(String(format: "%.4f", speed)):\(colors.identityKey)" - case .blobs(let colors, let background): - return "blobs:\(colors.identityKey)|bg:\(background.identityKey)" - case .particles(let particle, let background): - return "particles:\(particle.identityKey)|bg:\(background.identityKey)" - case .customGradient(let colors): - return "custom:\(colors.identityKey)" - case .adaptiveGradient(let light, let dark): - return "adaptive:l=\(light.identityKey)|d=\(dark.identityKey)" - } - } -} - -private extension Color { - var identityKey: String { - if let hex = toHex() { - return hex - } - // Fallback to descriptive string when hex can't be produced (e.g. dynamic colors) - return String(describing: self) - } -} diff --git a/StikJIT/Utilities/BackgroundAudioManager.swift b/StikJIT/Utilities/BackgroundAudioManager.swift new file mode 100644 index 00000000..3a712672 --- /dev/null +++ b/StikJIT/Utilities/BackgroundAudioManager.swift @@ -0,0 +1,114 @@ +// +// BackgroundAudioManager.swift +// StikJIT +// + +import AVFoundation + +final class BackgroundAudioManager { + static let shared = BackgroundAudioManager() + + private var engine = AVAudioEngine() + private var player = AVAudioPlayerNode() + private var isRunning = false + private var healthCheckTimer: Timer? + + private init() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleInterruption), + name: AVAudioSession.interruptionNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleMediaServicesReset), + name: AVAudioSession.mediaServicesWereResetNotification, + object: nil + ) + } + + func start() { + isRunning = true + startEngine() + startHealthCheck() + } + + func stop() { + isRunning = false + healthCheckTimer?.invalidate() + healthCheckTimer = nil + player.stop() + engine.stop() + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + + private func startEngine() { + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, options: .mixWithOthers) + try session.setActive(true) + + engine.attach(player) + let format = engine.mainMixerNode.outputFormat(forBus: 0) + engine.connect(player, to: engine.mainMixerNode, format: format) + + scheduleSilence() + try engine.start() + player.play() + } catch { + LogManager.shared.addErrorLog("BackgroundAudioManager: \(error.localizedDescription)") + } + } + + private func scheduleSilence() { + let format = engine.mainMixerNode.outputFormat(forBus: 0) + let frameCount = AVAudioFrameCount(format.sampleRate) + guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else { return } + buffer.frameLength = frameCount + // PCM buffer is zero-initialized — pure silence + player.scheduleBuffer(buffer, at: nil, options: .loops) + } + + // Runs every 2 seconds to reclaim the session if continuous game audio + // holds it and the interruption-ended notification never fires. + private func startHealthCheck() { + let timer = Timer(timeInterval: 2, repeats: true) { [weak self] _ in + self?.recoverIfNeeded() + } + RunLoop.main.add(timer, forMode: .common) + healthCheckTimer = timer + } + + private func recoverIfNeeded() { + guard isRunning, !engine.isRunning || !player.isPlaying else { return } + do { + try AVAudioSession.sharedInstance().setActive(true) + if !engine.isRunning { + try engine.start() + } + player.play() + } catch { + // Session still held by the game — will retry next tick + } + } + + @objc private func handleInterruption(_ notification: Notification) { + guard let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue), + type == .ended, + isRunning else { return } + + // Best-effort immediate resume; health check will cover failures. + try? AVAudioSession.sharedInstance().setActive(true) + if !engine.isRunning { try? engine.start() } + player.play() + } + + @objc private func handleMediaServicesReset() { + guard isRunning else { return } + engine = AVAudioEngine() + player = AVAudioPlayerNode() + startEngine() + } +} diff --git a/StikJIT/Utilities/BackgroundLocationManager.swift b/StikJIT/Utilities/BackgroundLocationManager.swift new file mode 100644 index 00000000..ac14603a --- /dev/null +++ b/StikJIT/Utilities/BackgroundLocationManager.swift @@ -0,0 +1,56 @@ +// +// BackgroundLocationManager.swift +// StikJIT +// + +import CoreLocation + +final class BackgroundLocationManager: NSObject, CLLocationManagerDelegate { + static let shared = BackgroundLocationManager() + + private let locationManager = CLLocationManager() + private var isRunning = false + + private override init() { + super.init() + locationManager.delegate = self + locationManager.desiredAccuracy = kCLLocationAccuracyThreeKilometers + locationManager.distanceFilter = CLLocationDistanceMax + locationManager.allowsBackgroundLocationUpdates = true + locationManager.pausesLocationUpdatesAutomatically = false + } + + func start() { + isRunning = true + switch locationManager.authorizationStatus { + case .authorizedAlways: + locationManager.startUpdatingLocation() + case .authorizedWhenInUse: + locationManager.requestAlwaysAuthorization() + case .notDetermined: + locationManager.requestAlwaysAuthorization() + default: + break + } + } + + func stop() { + isRunning = false + locationManager.stopUpdatingLocation() + } + + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + guard isRunning else { return } + switch manager.authorizationStatus { + case .authorizedAlways, .authorizedWhenInUse: + manager.startUpdatingLocation() + default: + break + } + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + // Location fixes may fail (e.g. no GPS indoors) — that's fine. + // The manager just needs to be running, not actually fix a location. + } +} diff --git a/StikJIT/Utilities/Color.swift b/StikJIT/Utilities/Color.swift deleted file mode 100644 index d3e53fe8..00000000 --- a/StikJIT/Utilities/Color.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// Color.swift -// StikJIT -// -// Created by Stephen on 3/27/25. -// - - -import SwiftUI - -extension Color { - func toHex() -> String? { - let components = UIColor(self).cgColor.components - let r = Float(components?[0] ?? 0) - let g = Float(components?[1] ?? 0) - let b = Float(components?[2] ?? 0) - let hex = String(format: "%02lX%02lX%02lX", lroundf(r * 255), lroundf(g * 255), lroundf(b * 255)) - return "#" + hex - } - - init?(hex: String) { - if hex.isEmpty || hex.count < 2 { - return nil - } - - let r, g, b: CGFloat - - if hex.hasPrefix("#") && hex.count >= 7 { - let start = hex.index(hex.startIndex, offsetBy: 1) - let hexColor = String(hex[start...]) - - if hexColor.count == 6, let hexNumber = Int(hexColor, radix: 16) { - r = CGFloat((hexNumber & 0xff0000) >> 16) / 255 - g = CGFloat((hexNumber & 0x00ff00) >> 8) / 255 - b = CGFloat(hexNumber & 0x0000ff) / 255 - self.init(red: r, green: g, blue: b) - return - } - } - - return nil - } -} - -extension Color { - static let primaryBackground = Color.black - static let cardBackground = Color.white.opacity(0.2) - - // Instead of a static color, provide a function that gets the current accent color - static func dynamicAccentColor(opacity: Double = 0.8) -> Color { - let colorHex = UserDefaults.standard.string(forKey: "customAccentColor") ?? "" - if colorHex.isEmpty { - return Color.blue.opacity(opacity) - } else { - return (Color(hex: colorHex) ?? .blue).opacity(opacity) - } - } - - // For backward compatibility - static var cardBackground2: Color { - return dynamicAccentColor(opacity: 0.8) - } - - static let primaryText = Color.white - static let secondaryText = Color.white.opacity(0.7) - - // Determine if a color is dark or light to decide text color - func isDark() -> Bool { - let uiColor = UIColor(self) - var red: CGFloat = 0 - var green: CGFloat = 0 - var blue: CGFloat = 0 - var alpha: CGFloat = 0 - - uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) - - // Calculate perceived brightness using the formula for luminance - // 0.299*R + 0.587*G + 0.114*B - let brightness = (0.299 * red) + (0.587 * green) + (0.114 * blue) - - // Return true if the color is dark (brightness < 0.5) - return brightness < 0.5 - } - - // Returns black or white depending on the background brightness - func contrastText() -> Color { - return self.isDark() ? .white : .black - } -} diff --git a/StikJIT/Utilities/DeviceConnectionContext.swift b/StikJIT/Utilities/DeviceConnectionContext.swift index a0499d7f..6439e80b 100644 --- a/StikJIT/Utilities/DeviceConnectionContext.swift +++ b/StikJIT/Utilities/DeviceConnectionContext.swift @@ -8,18 +8,11 @@ import Foundation enum DeviceConnectionContext { - static var isUsingExternalDevice: Bool { - DeviceLibraryStore.shared.isUsingExternalDevice - } - - static var requiresLoopbackVPN: Bool { - false - } - static var targetIPAddress: String { - if let device = DeviceLibraryStore.shared.activeDevice { - return device.ipAddress + let stored = UserDefaults.standard.string(forKey: "customTargetIP") + if let stored, !stored.isEmpty { + return stored } - return "127.0.0.1" + return "10.7.0.1" } } diff --git a/StikJIT/Views/DeviceInfoManager.swift b/StikJIT/Utilities/DeviceInfoManager.swift similarity index 75% rename from StikJIT/Views/DeviceInfoManager.swift rename to StikJIT/Utilities/DeviceInfoManager.swift index b06553f3..0bcf8699 100644 --- a/StikJIT/Views/DeviceInfoManager.swift +++ b/StikJIT/Utilities/DeviceInfoManager.swift @@ -166,64 +166,80 @@ struct DeviceInfoView: View { } } - @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) } var body: some View { NavigationStack { - ZStack { - ThemedBackground(style: backgroundStyle) - .ignoresSafeArea() - - ScrollView { - VStack(spacing: 20) { - infoCard + List { + if !isPaired { + Section { + Label("No pairing file detected", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text("Import your device's pairing file to get started.") + .font(.footnote) + .foregroundStyle(.secondary) } - .padding(.horizontal, 20) - .padding(.vertical, 30) } + if !mgr.entries.isEmpty { + Section { + ForEach(filteredEntries, id: \.key) { entry in + VStack(alignment: .leading, spacing: 4) { + Text(entry.key) + .font(.subheadline.weight(.semibold)) + Text(entry.value) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + .padding(.vertical, 2) + .contextMenu { + Button { copyToPasteboard(entry.value) } label: { + Label("Copy Value", systemImage: "doc.on.doc") + } + Button { copyToPasteboard("\(entry.key): \(entry.value)") } label: { + Label("Copy Key & Value", systemImage: "doc.on.clipboard") + } + } + } + } + } else if !mgr.busy && isPaired { + Section { + Text("No info available").foregroundStyle(.secondary) + } + } + } + .listStyle(.insetGrouped) + .searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search device info…" + ) + .navigationTitle("Device Info") + .overlay { if mgr.busy { Color.black.opacity(0.35).ignoresSafeArea() ProgressView("Fetching device info…") .padding(16) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) - ) - ) - .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } - - if alert { - CustomErrorView(title: alertTitle, - message: alertMsg, - onDismiss: { alert = false }, - messageType: alertSuccess ? .success : .error) - } - if justCopied { VStack { Spacer() Text("Copied") .font(.footnote.weight(.semibold)) - .padding(.horizontal, 14) - .padding(.vertical, 10) + .padding(.horizontal, 14).padding(.vertical, 10) .background(.ultraThinMaterial, in: Capsule()) - .overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 1)) - .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 3) .transition(.move(edge: .bottom).combined(with: .opacity)) .padding(.bottom, 30) } .animation(.easeInOut(duration: 0.25), value: justCopied) } } - .navigationTitle("Device Info") + .alert(alertTitle, isPresented: $alert) { + Button("OK", role: .cancel) { } + } message: { + Text(alertMsg) + } .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { if isPaired { @@ -282,64 +298,8 @@ struct DeviceInfoView: View { .onAppear { if isPaired { mgr.initAndLoad() } } .onDisappear { mgr.cleanup() } } - .preferredColorScheme(preferredScheme) - } - - // MARK: - UI Sections - - private var infoCard: some View { - VStack(alignment: .leading, spacing: 14) { - TextField("Search device info…", text: $searchText) - .padding(12) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(Color.white.opacity(0.12), lineWidth: 1) - ) - .padding(.bottom, 10) - - if !isPaired { - VStack(alignment: .leading, spacing: 6) { - Label("No pairing file detected", systemImage: "exclamationmark.triangle.fill") - .foregroundColor(.orange) - Text("Import your device’s pairing file to get started.") - .font(.footnote) - .foregroundColor(.secondary) - } - .padding(.vertical, 6) } - if mgr.entries.isEmpty { - Text(mgr.busy ? "Loading…" : (isPaired ? "No info available" : "")) - .foregroundColor(.secondary) - } else { - ForEach(filteredEntries, id: \.key) { entry in - VStack(alignment: .leading, spacing: 6) { - Text(entry.key).bold() - Text(entry.value) - .font(.caption.monospaced()) - .foregroundColor(.secondary) - .textSelection(.enabled) - } - .padding(.vertical, 4) - Divider() - .background(Color.white.opacity(0.12)) - } - } - } - .padding(20) - .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) - ) - ) - .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) - .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) - } - // MARK: - Copy / Share helpers private func allAsText() -> String { diff --git a/StikJIT/Utilities/DeviceLibraryStore.swift b/StikJIT/Utilities/DeviceLibraryStore.swift deleted file mode 100644 index 609b99db..00000000 --- a/StikJIT/Utilities/DeviceLibraryStore.swift +++ /dev/null @@ -1,313 +0,0 @@ -// -// DeviceLibraryStore.swift -// StikJIT -// -// Created by Stephen. -// - -import Foundation - -struct DeviceProfileEntry: Identifiable, Codable, Equatable { - var id: UUID - var name: String - var ipAddress: String - var pairingRelativePath: String - var pairingFilename: String - var dateAdded: Date - var lastUpdated: Date - var isTXM: Bool - - init(id: UUID, - name: String, - ipAddress: String, - pairingRelativePath: String, - pairingFilename: String, - dateAdded: Date, - lastUpdated: Date, - isTXM: Bool = false) { - self.id = id - self.name = name - self.ipAddress = ipAddress - self.pairingRelativePath = pairingRelativePath - self.pairingFilename = pairingFilename - self.dateAdded = dateAdded - self.lastUpdated = lastUpdated - self.isTXM = isTXM - } - - enum CodingKeys: String, CodingKey { - case id, name, ipAddress, pairingRelativePath, pairingFilename, dateAdded, lastUpdated, isTXM - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(UUID.self, forKey: .id) - name = try container.decode(String.self, forKey: .name) - ipAddress = try container.decode(String.self, forKey: .ipAddress) - pairingRelativePath = try container.decode(String.self, forKey: .pairingRelativePath) - pairingFilename = try container.decode(String.self, forKey: .pairingFilename) - dateAdded = try container.decode(Date.self, forKey: .dateAdded) - lastUpdated = try container.decode(Date.self, forKey: .lastUpdated) - isTXM = try container.decodeIfPresent(Bool.self, forKey: .isTXM) ?? false - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(name, forKey: .name) - try container.encode(ipAddress, forKey: .ipAddress) - try container.encode(pairingRelativePath, forKey: .pairingRelativePath) - try container.encode(pairingFilename, forKey: .pairingFilename) - try container.encode(dateAdded, forKey: .dateAdded) - try container.encode(lastUpdated, forKey: .lastUpdated) - try container.encode(isTXM, forKey: .isTXM) - } -} - -enum DeviceLibraryError: LocalizedError { - case missingPairingData - case deviceNotFound - case pairingFileUnavailable - case fileOperationFailed(String) - - var errorDescription: String? { - switch self { - case .missingPairingData: - return "Select a pairing file before saving." - case .deviceNotFound: - return "The selected device could not be found." - case .pairingFileUnavailable: - return "The pairing file for this device is missing. Re-import it and try again." - case .fileOperationFailed(let reason): - return reason - } - } -} - -final class DeviceLibraryStore: ObservableObject { - static let shared = DeviceLibraryStore() - - @Published private(set) var devices: [DeviceProfileEntry] = [] - @Published private(set) var activeDeviceID: UUID? - - var activeDevice: DeviceProfileEntry? { - guard let activeDeviceID else { return nil } - return devices.first(where: { $0.id == activeDeviceID }) - } - - var isUsingExternalDevice: Bool { - activeDeviceID != nil - } - - var defaultLocalDevice: DeviceProfileEntry { - DeviceProfileEntry( - id: localLoopbackID, - name: "This Device", - ipAddress: "10.7.0.1", - pairingRelativePath: "", - pairingFilename: "pairingFile.plist", - dateAdded: Date.distantPast, - lastUpdated: Date.distantPast - ) - } - - private let fileManager = FileManager.default - private let storageURL: URL - private let pairingsDirectory: URL - private let baseDirectory: URL - private let activeDeviceKey = "DeviceLibraryActiveDeviceID" - private let localLoopbackID = UUID(uuidString: "00000000-0000-0000-0000-000000000001") ?? UUID() - - private init() { - baseDirectory = URL.documentsDirectory.appendingPathComponent("DeviceLibrary", isDirectory: true) - pairingsDirectory = baseDirectory.appendingPathComponent("Pairings", isDirectory: true) - storageURL = baseDirectory.appendingPathComponent("devices.json") - createDirectoriesIfNeeded() - loadFromDisk() - if let rawValue = UserDefaults.standard.string(forKey: activeDeviceKey), - let uuid = UUID(uuidString: rawValue), - devices.contains(where: { $0.id == uuid }) { - activeDeviceID = uuid - } else { - UserDefaults.standard.removeObject(forKey: activeDeviceKey) - UserDefaults.standard.set(false, forKey: UserDefaults.Keys.usingExternalDevice) - activeDeviceID = nil - } - updateExternalDeviceFlag() - } - - // MARK: - Public API - - func refresh() { - loadFromDisk() - } - - func addDevice(name: String, - ipAddress: String, - pairingData: Data?, - originalFilename: String?, - isTXM: Bool) throws { - guard let pairingData else { - throw DeviceLibraryError.missingPairingData - } - - let id = UUID() - let now = Date() - let relativePath = try persistPairingData(pairingData, for: id) - let entry = DeviceProfileEntry( - id: id, - name: name.trimmingCharacters(in: .whitespacesAndNewlines), - ipAddress: ipAddress.trimmingCharacters(in: .whitespacesAndNewlines), - pairingRelativePath: relativePath, - pairingFilename: originalFilename ?? "pairingFile.plist", - dateAdded: now, - lastUpdated: now, - isTXM: isTXM - ) - devices.append(entry) - persistDevices() - } - - func update(device: DeviceProfileEntry, - name: String, - ipAddress: String, - pairingData: Data?, - originalFilename: String?, - isTXM: Bool) throws { - guard !isDefaultDevice(device) else { return } - guard let index = devices.firstIndex(where: { $0.id == device.id }) else { - throw DeviceLibraryError.deviceNotFound - } - - devices[index].name = name.trimmingCharacters(in: .whitespacesAndNewlines) - devices[index].ipAddress = ipAddress.trimmingCharacters(in: .whitespacesAndNewlines) - devices[index].lastUpdated = Date() - devices[index].isTXM = isTXM - if activeDeviceID == device.id { - UserDefaults.standard.set(devices[index].ipAddress, forKey: "TunnelDeviceIP") - } - - if let pairingData { - let relativePath = try persistPairingData(pairingData, for: device.id) - devices[index].pairingRelativePath = relativePath - if let originalFilename { - devices[index].pairingFilename = originalFilename - } - } - - persistDevices() - } - - func remove(device: DeviceProfileEntry) throws { - guard !isDefaultDevice(device) else { return } - guard let index = devices.firstIndex(where: { $0.id == device.id }) else { - throw DeviceLibraryError.deviceNotFound - } - - let relativePath = devices[index].pairingRelativePath - let storedURL = pairingsDirectory.appendingPathComponent(relativePath) - if fileManager.fileExists(atPath: storedURL.path) { - try? fileManager.removeItem(at: storedURL) - } - - devices.remove(at: index) - if activeDeviceID == device.id { - clearActiveDevice() - } - persistDevices() - } - - func activate(device: DeviceProfileEntry) throws { - if isDefaultDevice(device) { - clearActiveDevice() - return - } - let storedURL = pairingsDirectory.appendingPathComponent(device.pairingRelativePath) - guard fileManager.fileExists(atPath: storedURL.path) else { - throw DeviceLibraryError.pairingFileUnavailable - } - - UserDefaults.standard.set(device.ipAddress, forKey: "TunnelDeviceIP") - activeDeviceID = device.id - UserDefaults.standard.set(device.id.uuidString, forKey: activeDeviceKey) - UserDefaults.standard.set(true, forKey: UserDefaults.Keys.usingExternalDevice) - persistDevices() - updateExternalDeviceFlag() - } - - func clearActiveDevice() { - activeDeviceID = nil - UserDefaults.standard.removeObject(forKey: activeDeviceKey) - UserDefaults.standard.set(false, forKey: UserDefaults.Keys.usingExternalDevice) - UserDefaults.standard.removeObject(forKey: "TunnelDeviceIP") - updateExternalDeviceFlag() - } - - // MARK: - Persistence - - private func createDirectoriesIfNeeded() { - do { - if !fileManager.fileExists(atPath: baseDirectory.path) { - try fileManager.createDirectory(at: baseDirectory, withIntermediateDirectories: true) - } - if !fileManager.fileExists(atPath: pairingsDirectory.path) { - try fileManager.createDirectory(at: pairingsDirectory, withIntermediateDirectories: true) - } - } catch { - print("Failed to create DeviceLibrary directories: \(error)") - } - } - - private func persistPairingData(_ data: Data, for id: UUID) throws -> String { - createDirectoriesIfNeeded() - let filename = "\(id.uuidString).mobiledevicepairing" - let destination = pairingsDirectory.appendingPathComponent(filename) - if fileManager.fileExists(atPath: destination.path) { - try fileManager.removeItem(at: destination) - } - do { - try data.write(to: destination, options: .atomic) - try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: destination.path) - } catch { - throw DeviceLibraryError.fileOperationFailed("Unable to store pairing file. \(error.localizedDescription)") - } - return filename - } - - private func loadFromDisk() { - guard fileManager.fileExists(atPath: storageURL.path) else { - devices = [] - return - } - do { - let data = try Data(contentsOf: storageURL) - let decoded = try JSONDecoder().decode([DeviceProfileEntry].self, from: data) - devices = decoded - } catch { - print("Failed to load device library: \(error)") - devices = [] - } - if let activeDeviceID, !devices.contains(where: { $0.id == activeDeviceID }) { - clearActiveDevice() - } - updateExternalDeviceFlag() - } - - private func persistDevices() { - do { - createDirectoriesIfNeeded() - let data = try JSONEncoder().encode(devices) - try data.write(to: storageURL, options: .atomic) - } catch { - print("Failed to save device library: \(error)") - } - } - - private func updateExternalDeviceFlag() { - UserDefaults.standard.set(isUsingExternalDevice, forKey: UserDefaults.Keys.usingExternalDevice) - } - - func isDefaultDevice(_ device: DeviceProfileEntry) -> Bool { - device.id == localLoopbackID - } -} diff --git a/StikJIT/Utilities/Extensions.swift b/StikJIT/Utilities/Extensions.swift index 234eeeb9..d224f6e0 100644 --- a/StikJIT/Utilities/Extensions.swift +++ b/StikJIT/Utilities/Extensions.swift @@ -8,7 +8,7 @@ import UniformTypeIdentifiers extension FileManager { func filePath(atPath path: String, withLength length: Int) -> String? { - guard let file = try? contentsOfDirectory(atPath: path).filter({ $0.count == length }).first else { return nil } + guard let file = try? contentsOfDirectory(atPath: path).first(where: { $0.count == length }) else { return nil } return "\(path)/\(file)" } } @@ -23,7 +23,5 @@ extension UserDefaults { enum Keys { /// Forces the app to treat the current device as TXM-capable so scripts always run. static let txmOverride = "overrideTXMForScripts" - /// Tracks whether an external device profile is currently active. - static let usingExternalDevice = "UsingExternalDevice" } } diff --git a/StikJIT/Utilities/FeatureFlags.swift b/StikJIT/Utilities/FeatureFlags.swift deleted file mode 100644 index b7a145d3..00000000 --- a/StikJIT/Utilities/FeatureFlags.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation - -enum FeatureFlags { - /// Global toggle for exposing location spoofing UI/logic. - static let isLocationSpoofingEnabled = true - /// Controls visibility of beta-quality tabs and UI. - static let showBetaTabs = true - /// Forces the Theme Expansion to be unlocked and visible everywhere. - static let alwaysUnlockThemeExpansion = true -} diff --git a/StikJIT/Utilities/KeychainHelper.swift b/StikJIT/Utilities/KeychainHelper.swift deleted file mode 100644 index 606cc3a0..00000000 --- a/StikJIT/Utilities/KeychainHelper.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// KeychainHelper.swift -// StikDebug -// -// Created by Stephen on 7/29/25. -// - -import Security - -final class KeychainHelper { - static let shared = KeychainHelper() - private init() {} - - func save(password: String, forKey key: String) { - let pwData = password.data(using: .utf8)! - - let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key] - SecItemDelete(query as CFDictionary) - - let add: [String: Any] = [kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key, - kSecValueData as String: pwData, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock] - SecItemAdd(add as CFDictionary, nil) - } - - func readPassword(forKey key: String) -> String? { - let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne] - var out: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &out) - guard status == errSecSuccess, let data = out as? Data else { return nil } - return String(data: data, encoding: .utf8) - } - - func deletePassword(forKey key: String) { - let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key] - SecItemDelete(query as CFDictionary) - } -} diff --git a/StikJIT/Utilities/LogManager.swift b/StikJIT/Utilities/LogManager.swift index 91b2096f..7e90567f 100644 --- a/StikJIT/Utilities/LogManager.swift +++ b/StikJIT/Utilities/LogManager.swift @@ -7,97 +7,96 @@ import Foundation -class LogManager: ObservableObject { +final class LogManager: ObservableObject { static let shared = LogManager() - + @Published var logs: [LogEntry] = [] @Published var errorCount: Int = 0 - - struct LogEntry: Identifiable { - let id = UUID() + + struct LogEntry: Identifiable, Sendable { + let id: UUID let timestamp: Date let type: LogType let message: String - - enum LogType: String { - case info = "INFO" - case error = "ERROR" - case debug = "DEBUG" + + enum LogType: String, Sendable { + case info = "INFO" + case error = "ERROR" + case debug = "DEBUG" case warning = "WARNING" } + + init(timestamp: Date, type: LogType, message: String) { + self.id = UUID() + self.timestamp = timestamp + self.type = type + self.message = message + } } - + + private static let redundantPrefixes: [String] = [ + "Info: ", "INFO: ", "Information: ", + "Error: ", "ERROR: ", "ERR: ", + "Debug: ", "DEBUG: ", "DBG: ", + "Warning: ", "WARN: ", "WARNING: " + ] + private init() { - // Add initial system info logs addInfoLog("StikJIT starting up") addInfoLog("Initializing environment") } - + func addLog(message: String, type: LogEntry.LogType) { - //clean dumb stuff - var cleanMessage = message - - // Clean up common prefixes that match the log type - let prefixesToRemove = [ - "Info: ", "INFO: ", "Information: ", - "Error: ", "ERROR: ", "ERR: ", - "Debug: ", "DEBUG: ", "DBG: ", - "Warning: ", "WARN: ", "WARNING: " - ] - - for prefix in prefixesToRemove { - if cleanMessage.hasPrefix(prefix) { - cleanMessage = String(cleanMessage.dropFirst(prefix.count)) - break - } - } - + let clean = Self.redundantPrefixes + .first(where: { message.hasPrefix($0) }) + .map { String(message.dropFirst($0.count)) } ?? message + DispatchQueue.main.async { - self.logs.append(LogEntry(timestamp: Date(), type: type, message: cleanMessage)) - - if type == .error { - self.errorCount += 1 - } - - // Keep log size manageable - if self.logs.count > 1000 { - self.logs.removeFirst(100) - } + self.logs.append(LogEntry(timestamp: Date(), type: type, message: clean)) + if type == .error { self.errorCount += 1 } + if self.logs.count > 1000 { self.logs.removeFirst(100) } } } - - func addInfoLog(_ message: String) { - addLog(message: message, type: .info) - } - - func addErrorLog(_ message: String) { - addLog(message: message, type: .error) - } - - func addDebugLog(_ message: String) { - addLog(message: message, type: .debug) + + func addInfoLog(_ message: String) { addLog(message: message, type: .info) } + func addErrorLog(_ message: String) { addLog(message: message, type: .error) } + func addDebugLog(_ message: String) { addLog(message: message, type: .debug) } + func addWarningLog(_ message: String) { addLog(message: message, type: .warning) } + + func setLogs(_ entries: [LogEntry]) { + DispatchQueue.main.async { + self.logs = entries + self.errorCount = entries.filter { $0.type == .error }.count + } } - - func addWarningLog(_ message: String) { - addLog(message: message, type: .warning) + + func appendLogs(_ entries: [LogEntry], maxTotal: Int = 1000) { + DispatchQueue.main.async { + self.logs.append(contentsOf: entries) + self.errorCount += entries.filter { $0.type == .error }.count + if self.logs.count > maxTotal { + let excess = self.logs.count - maxTotal + let removed = self.logs.prefix(excess) + self.logs.removeFirst(excess) + let removedErrors = removed.filter { $0.type == .error }.count + self.errorCount = max(0, self.errorCount - removedErrors) + } + } } - + func clearLogs() { DispatchQueue.main.async { self.logs.removeAll() self.errorCount = 0 } } - + func removeOldestLogs(count: Int) { DispatchQueue.main.async { - // Remove the oldest logs and update error count - let removedLogs = self.logs.prefix(count) + let removed = self.logs.prefix(count) self.logs.removeFirst(count) - - // Update error count by counting removed error logs - let removedErrorCount = removedLogs.filter { $0.type == .error }.count - self.errorCount = max(0, self.errorCount - removedErrorCount) + let removedErrors = removed.filter { $0.type == .error }.count + self.errorCount = max(0, self.errorCount - removedErrors) } } -} +} diff --git a/StikJIT/Utilities/Security.swift b/StikJIT/Utilities/Security.swift index 73f1ebab..f49eb2a4 100644 --- a/StikJIT/Utilities/Security.swift +++ b/StikJIT/Utilities/Security.swift @@ -6,8 +6,8 @@ // import Security - typealias SecTaskRef = OpaquePointer + @_silgen_name("SecTaskCopyValueForEntitlement") func SecTaskCopyValueForEntitlement( _ task: SecTaskRef, @@ -21,15 +21,7 @@ func SecTaskCreateFromSelf( ) -> SecTaskRef? func checkAppEntitlement(_ ent: String) -> Bool { - guard let task = SecTaskCreateFromSelf(nil) else { - print("Failed to create SecTask") - return false - } - - guard let entitlements = SecTaskCopyValueForEntitlement(task, ent as NSString, nil) else { - print("Failed to get entitlements") - return false - } - - return entitlements.boolValue != nil && entitlements.boolValue + guard let task = SecTaskCreateFromSelf(nil) else { return false } + guard let value = SecTaskCopyValueForEntitlement(task, ent as NSString, nil) else { return false } + return value.boolValue != nil && value.boolValue } diff --git a/StikJIT/Utilities/SystemLogStream.swift b/StikJIT/Utilities/SystemLogStream.swift index 5e565fc5..5e961cf2 100644 --- a/StikJIT/Utilities/SystemLogStream.swift +++ b/StikJIT/Utilities/SystemLogStream.swift @@ -25,7 +25,13 @@ final class SystemLogStream: ObservableObject { if updateInterval < 0 { updateInterval = 0 } flushTimer?.invalidate() flushTimer = nil - flushImmediatelyIfNeeded() + if !isPaused && !pendingEntries.isEmpty { + if updateInterval == 0 { + flushAllPending() + } else { + scheduleFlushIfNeeded() + } + } } } @@ -33,6 +39,8 @@ final class SystemLogStream: ObservableObject { private var pendingEntries: [Entry] = [] private var flushTimer: Timer? private var retryTimer: Timer? + private var batchTimer: Timer? + private static let batchInterval: TimeInterval = 0.1 func start() { retryTimer?.invalidate() @@ -44,6 +52,7 @@ final class SystemLogStream: ObservableObject { isStreaming = true isPaused = false lastError = nil + startBatchTimer() JITEnableContext.shared.startSyslogRelay(handler: { [weak self] line in guard let line else { return } @@ -60,6 +69,8 @@ final class SystemLogStream: ObservableObject { JITEnableContext.shared.stopSyslogRelay() flushTimer?.invalidate() flushTimer = nil + batchTimer?.invalidate() + batchTimer = nil pendingEntries.removeAll() retryTimer?.invalidate() retryTimer = nil @@ -88,7 +99,11 @@ final class SystemLogStream: ObservableObject { func resume() { guard isPaused else { return } isPaused = false - flushImmediatelyIfNeeded() + if updateInterval == 0 { + flushAllPending() + } else { + scheduleFlushIfNeeded() + } } private func handleLine(_ line: String) { @@ -101,9 +116,8 @@ final class SystemLogStream: ObservableObject { if pendingEntries.count > maxEntries { pendingEntries.removeFirst(pendingEntries.count - maxEntries) } - if updateInterval == 0 && !isPaused { - flushImmediatelyIfNeeded() - } else { + // Batching is handled by batchTimer / scheduleFlushIfNeeded + if updateInterval > 0 { scheduleFlushIfNeeded() } } @@ -113,10 +127,33 @@ final class SystemLogStream: ObservableObject { isPaused = false flushTimer?.invalidate() flushTimer = nil + batchTimer?.invalidate() + batchTimer = nil lastError = error?.localizedDescription ?? "System log stream stopped" scheduleAutoRetry() } + private func startBatchTimer() { + batchTimer?.invalidate() + batchTimer = Timer.scheduledTimer(withTimeInterval: Self.batchInterval, repeats: true) { [weak self] _ in + guard let self else { return } + guard !self.isPaused, self.updateInterval == 0, !self.pendingEntries.isEmpty else { return } + self.flushAllPending() + } + if let batchTimer { + RunLoop.main.add(batchTimer, forMode: .common) + } + } + + private func flushAllPending() { + guard !pendingEntries.isEmpty else { return } + entries.append(contentsOf: pendingEntries) + pendingEntries.removeAll() + if entries.count > maxEntries { + entries.removeFirst(entries.count - maxEntries) + } + } + private func prettify(line: String) -> String { if let range = line.range(of: ": ") { let messagePart = line[range.upperBound...] @@ -131,32 +168,12 @@ final class SystemLogStream: ObservableObject { .joined(separator: "\n") } - private func appendEntry(_ entry: Entry) { - entries.append(entry) - if entries.count > maxEntries { - entries.removeFirst(entries.count - maxEntries) - } - } - - private func flushImmediatelyIfNeeded() { - guard !isPaused else { return } - if updateInterval == 0 { - while !pendingEntries.isEmpty { - let next = pendingEntries.removeFirst() - appendEntry(next) - } - } else { - scheduleFlushIfNeeded() - } - } - private func scheduleFlushIfNeeded() { guard !isPaused else { return } guard !pendingEntries.isEmpty else { return } guard flushTimer == nil else { return } - - if updateInterval <= 0 { - flushImmediatelyIfNeeded() + guard updateInterval > 0 else { + // Live mode uses batchTimer instead return } @@ -164,10 +181,7 @@ final class SystemLogStream: ObservableObject { guard let self else { return } self.flushTimer = nil guard !self.isPaused else { return } - if let next = self.pendingEntries.first { - self.pendingEntries.removeFirst() - self.appendEntry(next) - } + self.flushAllPending() if !self.pendingEntries.isEmpty { self.scheduleFlushIfNeeded() } diff --git a/StikJIT/Utilities/TabConfiguration.swift b/StikJIT/Utilities/TabConfiguration.swift index e8614b06..b8b51af5 100644 --- a/StikJIT/Utilities/TabConfiguration.swift +++ b/StikJIT/Utilities/TabConfiguration.swift @@ -2,21 +2,16 @@ import Foundation enum TabConfiguration { static let storageKey = "enabledTabIdentifiers" - static let maxSelectableTabs = 4 - private static let coreIDs: [String] = ["home", "console", "scripts", "deviceinfo"] + static let maxSelectableTabs = 12 + private static let coreIDs: [String] = ["home", "scripts", "tools", "deviceinfo"] static var allowedIDs: [String] { var ids = coreIDs - if FeatureFlags.showBetaTabs { - ids.append("profiles") - ids.append("processes") - ids.append("devicelibrary") - if FeatureFlags.isLocationSpoofingEnabled { - ids.append("location") - } - } + ids.append("profiles") + ids.append("processes") + ids.append("location") return ids } - static let defaultIDs: [String] = ["home", "console", "scripts", "deviceinfo"] + static let defaultIDs: [String] = ["home", "scripts", "tools", "deviceinfo", "profiles", "processes", "location"] static let defaultRawValue = serialize(defaultIDs) static func sanitize(raw: String) -> [String] { diff --git a/StikJIT/Utilities/ThemeExpansionManager.swift b/StikJIT/Utilities/ThemeExpansionManager.swift deleted file mode 100644 index 1259653f..00000000 --- a/StikJIT/Utilities/ThemeExpansionManager.swift +++ /dev/null @@ -1,437 +0,0 @@ -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) - } -} - -// MARK: - Distribution detection (receipt-based) - -enum DistributorType: String { - case appStore - case testFlight - case other -} - -@MainActor -final class ThemeExpansionManager: ObservableObject { - static let productIdentifier = "SD_Theme_Expansion" - static let comingSoonMessage = "Theme Expansion is coming soon on this store." - static let unavailableMessage = "Theme Expansion isn’t available." - - @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] = [] - - // New: distribution awareness - @Published private(set) var distributor: DistributorType - var isAppStoreBuild: Bool { distributor == .appStore } - var shouldShowThemeExpansionUpsell: Bool { - guard !isForcedUnlocked else { return false } - guard isAppStoreBuild else { return false } - if let lastError, lastError == Self.unavailableMessage { - return false - } - return true - } - - private var updatesTask: Task? - private let isPreviewInstance: Bool - private let isForcedUnlocked: Bool - private let customThemesKey = "ThemeExpansion.CustomThemes" - - init(previewUnlocked: Bool = false) { - self.isPreviewInstance = previewUnlocked - self.isForcedUnlocked = FeatureFlags.alwaysUnlockThemeExpansion - self.distributor = ThemeExpansionManager.detectDistributor() - self.hasThemeExpansion = previewUnlocked || isForcedUnlocked - loadCustomThemes() - - if (previewUnlocked || isForcedUnlocked) && customThemes.isEmpty { - customThemes = [ - CustomTheme(name: "Vapor Trail", - style: .animatedGradient, - colorHexes: ["#FF00E0", "#00D0FF", "#7A00FF"], - preferredColorScheme: .dark) - ] - } - - guard !(previewUnlocked || isForcedUnlocked) else { return } - - // Only wire StoreKit listeners if this is an App Store build - if isAppStoreBuild { - updatesTask = Task { [weak self] in - guard let self else { return } - for await result in StoreKit.Transaction.updates { - await self.handle(transactionResult: result) - } - } - - Task { await refreshEntitlements() } - } else { - // Non–App Store builds cannot purchase yet; keep everything locked and quiet - self.hasThemeExpansion = false - self.themeExpansionProduct = nil - self.lastError = nil - } - } - - deinit { - updatesTask?.cancel() - } - - // MARK: - Public API - - func refreshEntitlements() async { - guard !isPreviewInstance else { return } - guard !isForcedUnlocked else { - hasThemeExpansion = true - themeExpansionProduct = nil - lastError = nil - return - } - guard isAppStoreBuild else { return } // No-op outside App Store - - isProcessing = true - defer { isProcessing = false } - do { - lastError = nil - let products = try await Product.products(for: [Self.productIdentifier]) - themeExpansionProduct = products.first - - if products.isEmpty { - #if targetEnvironment(simulator) - lastError = """ - No products found for. - """ - #else - lastError = """ - No products found for. - """ - #endif - } - - // Recompute entitlement from current entitlements - hasThemeExpansion = await isEntitledToThemeExpansion() - } catch { - lastError = error.localizedDescription - } - } - - func restorePurchases() async { - guard !isPreviewInstance else { return } - guard !isForcedUnlocked else { - lastError = nil - return - } - guard isAppStoreBuild else { - lastError = Self.comingSoonMessage - return - } - isProcessing = true - defer { isProcessing = false } - do { - lastError = nil - try await AppStore.sync() - hasThemeExpansion = await isEntitledToThemeExpansion() - } catch { - lastError = error.localizedDescription - } - } - - func purchaseThemeExpansion() async { - guard !isPreviewInstance else { return } - guard !isForcedUnlocked else { - lastError = nil - hasThemeExpansion = true - return - } - guard isAppStoreBuild else { - lastError = Self.comingSoonMessage - 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 { - #if targetEnvironment(simulator) - lastError = Self.unavailableMessage - #else - lastError = Self.unavailableMessage - #endif - 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() - } - - // MARK: - Persistence - - 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] - } - - // MARK: - StoreKit plumbing - - private func handle(transactionResult: VerificationResult, finishTransaction: Bool = true) async { - switch transactionResult { - case .verified(let transaction): - if transaction.productID == Self.productIdentifier { - hasThemeExpansion = (transaction.revocationDate == nil) - lastError = nil - } - if finishTransaction { - await transaction.finish() - } - case .unverified(_, let error): - lastError = error.localizedDescription - } - } - - private func isEntitledToThemeExpansion() async -> Bool { - for await result in Transaction.currentEntitlements { - if case .verified(let transaction) = result, transaction.productID == Self.productIdentifier { - return transaction.revocationDate == nil - } - } - return false - } - - // MARK: - Distributor detection helper - - private static func detectDistributor() -> DistributorType { - guard let receiptURL = Bundle.main.appStoreReceiptURL else { - return .other - } - let path = receiptURL.path - if FileManager.default.fileExists(atPath: path) { - // TestFlight builds use "sandboxReceipt" - return receiptURL.lastPathComponent == "sandboxReceipt" ? .testFlight : .appStore - } else { - return .other - } - } -} - -#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) - } -} diff --git a/StikJIT/Utilities/mountDDI.swift b/StikJIT/Utilities/mountDDI.swift index 9eee5eac..07f7b8f9 100644 --- a/StikJIT/Utilities/mountDDI.swift +++ b/StikJIT/Utilities/mountDDI.swift @@ -18,23 +18,30 @@ func progressCallback(progress: size_t, total: size_t, context: UnsafeMutableRaw MountingProgress.shared.progressCallback(progress: progress, total: total, context: context) } +enum MountCheckResult { + case mounted + case notMounted + case unreachable +} + func isMounted() -> Bool { + return checkMountStatus() == .mounted +} + +func checkMountStatus() -> MountCheckResult { do { let result = try JITEnableContext.shared.getMountedDeviceCount() - return result > 0 + return result > 0 ? .mounted : .notMounted } catch { - print("Error while getMountedDeviceCount \(error)") - return false + return .unreachable } } func mountPersonalDDI(imagePath: String, trustcachePath: String, manifestPath: String) -> Int { - print("Mounting \(imagePath) \(trustcachePath) \(manifestPath)") - do { try JITEnableContext.shared.mountPersonalDDI(withImagePath: imagePath, trustcachePath: trustcachePath, manifestPath: manifestPath) } catch { - print("Failed to mount ddi: \(error)") + LogManager.shared.addErrorLog("Failed to mount DDI: \(error.localizedDescription)") return (error as NSError).code } return 0 diff --git a/StikJIT/Views/ConsoleLogsView.swift b/StikJIT/Views/ConsoleLogsView.swift index 09f9ee44..7855783e 100644 --- a/StikJIT/Views/ConsoleLogsView.swift +++ b/StikJIT/Views/ConsoleLogsView.swift @@ -10,13 +10,10 @@ import UIKit 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 selectedConsoleTab: ConsoleTab = .idevice @State private var jitScrollView: ScrollViewProxy? = nil - @AppStorage("customAccentColor") private var customAccentColorHex: String = "" - @State private var showingCustomAlert = false @State private var alertMessage = "" @State private var alertTitle = "" @@ -29,136 +26,89 @@ struct ConsoleLogsView: View { @State private var isLoadingLogs = false @State private var jitIsAtBottom = true @State private var syslogIsAtBottom = true + @State private var syslogSearchText = "" @State private var showingSyslogSpeedSelector = false private let appLogRefreshInterval: TimeInterval = 3.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 let syslogIntervalOptions: [Double] = [0.0, 0.2, 0.5, 1.0, 1.5, 2.0] - private var accentColor: Color { - themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue - } - private var overlayOpacity: Double { - colorScheme == .dark ? 0.82 : 0.9 + private var filteredSyslogEntries: [SystemLogStream.Entry] { + if syslogSearchText.isEmpty { + return systemLogStream.entries + } + let query = syslogSearchText.lowercased() + return systemLogStream.entries.filter { $0.raw.lowercased().contains(query) } } var body: some View { - NavigationView { - ZStack { - ThemedBackground(style: backgroundStyle) - .ignoresSafeArea() - Color(colorScheme == .dark ? .black : .white) - .opacity(overlayOpacity) - .ignoresSafeArea() - - VStack(spacing: 0) { - if selectedConsoleTab == .syslog { - syslogBetaTag - } - - Group { - if selectedConsoleTab == .idevice { - jitLogsPane - } else { - syslogLogsPane - } + NavigationStack { + Group { + if selectedConsoleTab == .idevice { + jitLogsPane + } else { + syslogLogsPane + } + } + .navigationTitle("Console") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Picker("", selection: $selectedConsoleTab) { + Text("App").tag(ConsoleTab.idevice) + Text("System").tag(ConsoleTab.syslog) } - - Spacer(minLength: 0) - - Group { + .pickerStyle(.segmented) + .frame(width: 180) + } + ToolbarItem(placement: .navigationBarTrailing) { + Menu { if selectedConsoleTab == .idevice { - jitFooter + Button("Refresh", systemImage: "arrow.clockwise") { + Task { await loadIdeviceLogsAsync() } + } + Button("Clear", systemImage: "trash", role: .destructive) { + logManager.clearLogs() + } + Button("Copy Logs", systemImage: "doc.on.doc") { + copyJITLogs() + } + exportMenuOption } else { - syslogFooter + Button(syslogControlLabel, systemImage: syslogControlIcon) { + toggleSyslogPlayback() + } + Button("Clear", systemImage: "trash", role: .destructive) { + systemLogStream.clear() + } + Button("Copy Logs", systemImage: "doc.on.doc") { + copySyslogToClipboard() + } + Button("Adjust Speed", systemImage: "slider.horizontal.3") { + showingSyslogSpeedSelector = true + } } + } label: { + Image(systemName: "ellipsis.circle") } } - .padding(.top, 12) - .navigationBarTitleDisplayMode(.inline) } - .overlay( - Group { - if showingCustomAlert { - Color.black.opacity(0.4) - .edgesIgnoringSafeArea(.all) - .overlay( - CustomErrorView( - title: alertTitle, - message: alertMessage, - onDismiss: { - showingCustomAlert = false - }, - showButton: true, - primaryButtonText: "OK", - messageType: isError ? .error : .success - ) - ) - } - if showingSyslogSpeedSelector { - Color.black.opacity(0.45) - .ignoresSafeArea() - .onTapGesture { showingSyslogSpeedSelector = false } - .overlay( - appGlassCard { - VStack(spacing: 18) { - HStack { - Text("Syslog Speed") - .font(.title3.weight(.semibold)) - Spacer() - Button { - showingSyslogSpeedSelector = false - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundColor(.secondary) - } - .buttonStyle(.plain) - } - Text("Choose how quickly new relay entries appear.") - .font(.footnote) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - - VStack(spacing: 10) { - ForEach(syslogIntervalOptions, id: \.self) { option in - Button { - systemLogStream.updateInterval = option - showingSyslogSpeedSelector = false - } label: { - HStack { - Text(intervalLabel(for: option)) - .font(.headline) - Spacer() - if abs(systemLogStream.updateInterval - option) < 0.01 { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(accentColor) - } - } - .padding(.horizontal, 18) - .frame(maxWidth: .infinity, minHeight: 44) - } - .buttonStyle( - GlassOvalButtonStyle( - height: 44, - strokeOpacity: abs(systemLogStream.updateInterval - option) < 0.01 ? 0.35 : 0.15 - ) - ) - } - } - } - } - .padding(.horizontal, 30) - ) + .alert(alertTitle, isPresented: $showingCustomAlert) { + Button("OK", role: .cancel) { } + } message: { + Text(alertMessage) + } + .confirmationDialog("Syslog Speed", isPresented: $showingSyslogSpeedSelector) { + ForEach(syslogIntervalOptions, id: \.self) { option in + Button(intervalLabel(for: option)) { + systemLogStream.updateInterval = option } } - ) + Button("Cancel", role: .cancel) { } + } message: { + Text("Choose how quickly new relay entries appear.") + } } - .navigationViewStyle(.stack) - .preferredColorScheme(preferredScheme) - .onDisappear { + .onDisappear { systemLogStream.stop() } } @@ -166,7 +116,7 @@ struct ConsoleLogsView: View { private var jitLogsPane: some View { ScrollViewReader { proxy in ScrollView { - VStack(spacing: 0) { + LazyVStack(spacing: 0) { VStack(alignment: .leading, spacing: 4) { Text("=== DEVICE INFORMATION ===") .font(.system(size: 11, design: .monospaced)) @@ -238,241 +188,108 @@ struct ConsoleLogsView: View { } private var syslogLogsPane: some View { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 4) { - ForEach(systemLogStream.entries) { entry in - Text(AttributedString(createSyslogAttributedString(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) + VStack(spacing: 0) { + HStack { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Filter logs", text: $syslogSearchText) + .textFieldStyle(.plain) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + if !syslogSearchText.isEmpty { + Button { + syslogSearchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) } } - .background( - GeometryReader { geometry in - Color.clear.preference( - key: ScrollOffsetPreferenceKey.self, - value: geometry.frame(in: .named("syslogScroll")).minY - ) - } - ) } - .coordinateSpace(name: "syslogScroll") - .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in - syslogIsAtBottom = offset > -20 - } - .onChange(of: systemLogStream.entries.count) { _ in - guard syslogIsAtBottom, let lastLog = systemLogStream.entries.last else { return } - withAnimation { - proxy.scrollTo(lastLog.id, anchor: .bottom) - } - } - .onAppear { - if selectedConsoleTab == .syslog && !systemLogStream.isStreaming { - systemLogStream.start() - } - } - .onDisappear { - systemLogStream.stop() - } - } - } - - private var jitFooter: some View { - HStack(spacing: 12) { - HStack(spacing: 6) { - Image(systemName: "exclamationmark.triangle.fill") - .imageScale(.small) - .foregroundColor(.red) - Text("\(logManager.errorCount)") - .font(.subheadline.weight(.semibold)) - .foregroundColor(.secondary) - } - Spacer(minLength: 8) - - Button { - Task { await loadIdeviceLogsAsync() } - } label: { - Image(systemName: "arrow.clockwise") - .foregroundColor(accentColor) - } - .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) - .accessibilityLabel("Refresh app logs") - - Button { - logManager.clearLogs() - } label: { - Image(systemName: "trash") - .foregroundColor(accentColor) - } - .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) - .accessibilityLabel("Clear app logs") - - Button { - var logsContent = "=== DEVICE INFORMATION ===\n" - logsContent += "Version: \(UIDevice.current.systemVersion)\n" - logsContent += "Name: \(UIDevice.current.name)\n" - logsContent += "Model: \(UIDevice.current.model)\n" - logsContent += "StikJIT Version: App Version: 1.0\n\n" - logsContent += "=== LOG ENTRIES ===\n" - - logsContent += logManager.logs.map { - "[\(formatTime(date: $0.timestamp))] [\($0.type.rawValue)] \($0.message)" - }.joined(separator: "\n") - - UIPasteboard.general.string = logsContent - - alertTitle = "Logs Copied" - alertMessage = "Logs have been copied to clipboard." - isError = false - showingCustomAlert = true - } label: { - Image(systemName: "doc.on.doc") - .foregroundColor(accentColor) - } - .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) - .accessibilityLabel("Copy app logs") - - exportControl - - Button { - selectedConsoleTab = .syslog - } label: { - Image(systemName: "rectangle.2.swap") - .foregroundColor(accentColor) - } - .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) - .accessibilityLabel("Switch to syslog relay") - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - } - - private var syslogFooter: some View { - VStack(spacing: 10) { - if let error = systemLogStream.lastError { - Text(error) - .font(.caption) - .foregroundColor(.red) - .frame(maxWidth: .infinity, alignment: .leading) - } - - HStack(spacing: 12) { - HStack(spacing: 6) { - Image(systemName: "exclamationmark.triangle.fill") - .imageScale(.small) - .foregroundColor(.red) - Text("\(syslogErrorCount)") - .font(.subheadline.weight(.semibold)) - .foregroundColor(.secondary) - } - - Spacer(minLength: 8) - - Button(action: toggleSyslogPlayback) { - Image(systemName: syslogControlIcon) - .foregroundColor(accentColor) + .padding(8) + .background(.bar) + + Divider() + + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 4) { + ForEach(filteredSyslogEntries) { entry in + Text(AttributedString(createSyslogAttributedString(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("syslogScroll")).minY + ) + } + ) } - .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) - .accessibilityLabel(syslogControlLabel) - - Button(action: { systemLogStream.clear() }) { - Image(systemName: "trash") - .foregroundColor(accentColor) + .coordinateSpace(name: "syslogScroll") + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in + syslogIsAtBottom = offset > -20 } - .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) - .accessibilityLabel("Clear syslog entries") - - Button(action: copySyslogToClipboard) { - Image(systemName: "doc.on.doc") - .foregroundColor(accentColor) + .onChange(of: systemLogStream.entries.count) { _ in + guard syslogIsAtBottom, syslogSearchText.isEmpty, + let lastLog = systemLogStream.entries.last else { return } + withAnimation { + proxy.scrollTo(lastLog.id, anchor: .bottom) + } } - .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) - .accessibilityLabel("Copy syslog entries") - - Button { - showingSyslogSpeedSelector.toggle() - } label: { - Image(systemName: "slider.horizontal.3") - .foregroundColor(accentColor) + .onAppear { + if selectedConsoleTab == .syslog && !systemLogStream.isStreaming { + systemLogStream.start() + } } - .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) - .accessibilityLabel("Adjust syslog speed") - - Button { - selectedConsoleTab = .idevice - } label: { - Image(systemName: "rectangle.2.swap") - .foregroundColor(accentColor) + .onDisappear { + systemLogStream.stop() } - .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) - .accessibilityLabel("Switch to app logs") } } - .padding(.horizontal, 16) - .padding(.vertical, 10) } - private var syslogBetaTag: some View { - HStack(spacing: 10) { - Label { - Text("Syslogs") - .font(.headline.weight(.semibold)) - } icon: { - Image(systemName: "waveform.path.ecg") - .foregroundColor(accentColor) - } - - Text("BETA") - .font(.caption2.weight(.bold)) - .padding(.horizontal, 10) - .padding(.vertical, 4) - .foregroundColor(accentColor) - .background( - Capsule() - .fill(accentColor.opacity(colorScheme == .dark ? 0.22 : 0.12)) - ) - .overlay( - Capsule() - .stroke(accentColor.opacity(0.45), lineWidth: 1) - ) - - Spacer(minLength: 0) - } - .padding(.horizontal, 16) - .padding(.bottom, 8) - .transition(.opacity) + private func copyJITLogs() { + var logsContent = "=== DEVICE INFORMATION ===\n" + logsContent += "Version: \(UIDevice.current.systemVersion)\n" + logsContent += "Name: \(UIDevice.current.name)\n" + logsContent += "Model: \(UIDevice.current.model)\n" + logsContent += "StikJIT Version: App Version: 1.0\n\n" + logsContent += "=== LOG ENTRIES ===\n" + logsContent += logManager.logs.map { + "[\(formatTime(date: $0.timestamp))] [\($0.type.rawValue)] \($0.message)" + }.joined(separator: "\n") + UIPasteboard.general.string = logsContent + alertTitle = "Logs Copied" + alertMessage = "Logs have been copied to clipboard." + isError = false + showingCustomAlert = true } @ViewBuilder - private var exportControl: some View { + private var exportMenuOption: some View { let logURL: URL = URL.documentsDirectory.appendingPathComponent("idevice_log.txt") if FileManager.default.fileExists(atPath: logURL.path) { ShareLink( item: logURL, preview: SharePreview("idevice_log.txt", image: Image(systemName: "doc.text")) ) { - Image(systemName: "square.and.arrow.up") - .foregroundColor(accentColor) + Label("Export Logs", systemImage: "square.and.arrow.up") } - .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) - .accessibilityLabel("Export app logs") } else { - Button { + Button("Export Logs", systemImage: "square.and.arrow.up") { alertTitle = "Export Failed" alertMessage = "No idevice logs found" isError = true showingCustomAlert = true - } label: { - Image(systemName: "square.and.arrow.up") - .foregroundColor(accentColor) } - .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) - .accessibilityLabel("Export app logs") } } @@ -544,7 +361,7 @@ struct ConsoleLogsView: View { case .error: return .red case .debug: - return accentColor + return .blue case .warning: return .orange } @@ -579,9 +396,9 @@ struct ConsoleLogsView: View { private func loadIdeviceLogsAsync() async { guard !isLoadingLogs else { return } isLoadingLogs = true - + let logPath = URL.documentsDirectory.appendingPathComponent("idevice_log.txt").path - + guard FileManager.default.fileExists(atPath: logPath) else { await MainActor.run { logManager.addInfoLog("No idevice logs found (Restart the app to continue reading)") @@ -589,53 +406,55 @@ struct ConsoleLogsView: View { } return } - - do { - let logContent = try String(contentsOfFile: logPath, encoding: .utf8) - let lines = logContent.components(separatedBy: .newlines) - - let maxLines = 500 - let startIndex = max(0, lines.count - maxLines) - let recentLines = Array(lines[startIndex.. lastProcessedLineCount { - let newLines = Array(lines[lastProcessedLineCount.. maxLines { - let excessCount = logManager.logs.count - maxLines - logManager.removeOldestLogs(count: excessCount) + + // Parse new lines on a background thread + let result: ([LogManager.LogEntry], Int)? = await Task.detached(priority: .userInitiated) { + do { + let logContent = try String(contentsOfFile: logPath, encoding: .utf8) + let lines = logContent.components(separatedBy: .newlines) + + guard lines.count > previousCount else { return ([], lines.count) } + + let newLines = lines[previousCount.. some View { - configuration.label - .padding(.horizontal, 14) - .frame(height: height) - .background(.ultraThinMaterial, in: Capsule()) - .overlay( - Capsule() - .stroke(.white.opacity(strokeOpacity), lineWidth: 1) - ) - .shadow(color: .black.opacity(0.08), radius: configuration.isPressed ? 4 : 10, x: 0, y: configuration.isPressed ? 1 : 4) - .scaleEffect(configuration.isPressed ? 0.98 : 1) - } -} struct ConsoleLogsView_Previews: PreviewProvider { static var previews: some View { ConsoleLogsView() - .themeExpansionManager(ThemeExpansionManager(previewUnlocked: true)) } } diff --git a/StikJIT/Views/Custom Error View.swift b/StikJIT/Views/Custom Error View.swift deleted file mode 100644 index cf80964d..00000000 --- a/StikJIT/Views/Custom Error View.swift +++ /dev/null @@ -1,164 +0,0 @@ -// -// CustomErrorView.swift -// StikJIT -// -// Created by neoarz on 3/29/25. -// - -import SwiftUI - -public enum MessageType { - case error - case success - case info -} - -struct CustomErrorView: View { - var title: String - var message: String - var onDismiss: () -> Void - var showButton: Bool = true - @State private var opacity: Double = 0 - @State private var scale: CGFloat = 0.8 - var primaryButtonText: String = "OK" - var secondaryButtonText: String = "Cancel" - var onPrimaryButtonTap: (() -> Void)? = nil - var onSecondaryButtonTap: (() -> Void)? = nil - var showSecondaryButton: Bool = false - var messageType: MessageType = .error - - @Environment(\.colorScheme) private var colorScheme - @AppStorage("customAccentColor") private var customAccentColorHex: String = "" - - private var accentColor: Color { - if customAccentColorHex.isEmpty { return .blue } - return Color(hex: customAccentColorHex) ?? .blue - } - - - var body: some View { - ZStack { - Color.black.opacity(0.6) - .edgesIgnoringSafeArea(.all) - .opacity(opacity) - .onTapGesture { - if showButton { - dismissWithAnimation() - } - } - - VStack(spacing: 12) { - switch messageType { - case .error: - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 32)) - .foregroundColor(.red.opacity(0.9)) - .padding(.top, 8) - case .success: - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 32)) - .foregroundColor(.green.opacity(0.9)) - .padding(.top, 8) - case .info: - Image(systemName: "info.circle.fill") - .font(.system(size: 32)) - .foregroundColor(accentColor.opacity(0.9)) - .padding(.top, 8) - } - - Text(title) - .font(.system(size: 18, weight: .bold, design: .rounded)) - .foregroundColor(colorScheme == .dark ? .white : .black) - .multilineTextAlignment(.center) - - Rectangle() - .frame(height: 1) - .foregroundColor(colorScheme == .dark ? .white.opacity(0.2) : .black.opacity(0.2)) - .padding(.horizontal, 12) - - Text(LocalizedStringKey(message)) - .font(.system(size: 15, design: .rounded)) - .foregroundColor(colorScheme == .dark ? .white.opacity(0.9) : .black.opacity(0.9)) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .padding(.horizontal, 12) - - - if showButton { - VStack(spacing: 6) { - Button(action: { - dismissWithAnimation() - onPrimaryButtonTap?() - }) { - Text(primaryButtonText) - .font(.system(size: 16, weight: .semibold, design: .rounded)) - .foregroundColor(colorScheme == .dark ? .black : .white) - .frame(height: 38) - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(colorScheme == .dark ? Color.white : accentColor) - ) - } - - if showSecondaryButton { - Button(action: { - dismissWithAnimation() - onSecondaryButtonTap?() - }) { - Text(secondaryButtonText) - .font(.system(size: 16, weight: .medium, design: .rounded)) - .foregroundColor(colorScheme == .dark ? .white : .gray) - .frame(height: 38) - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(colorScheme == .dark ? Color.gray.opacity(0.3) : Color.gray.opacity(0.15)) - ) - } - } - } - .padding(.horizontal, 12) - .padding(.bottom, 12) - .padding(.top, 6) - } else { - Spacer() - .frame(height: 12) - } - } - .frame(width: min(UIScreen.main.bounds.width - 80, 300)) - .background( - RoundedRectangle(cornerRadius: 16) - .fill(colorScheme == .dark ? - Color(UIColor.systemGray6).opacity(0.95) : - Color(UIColor.systemGray6).opacity(0.95)) - .overlay( - RoundedRectangle(cornerRadius: 16) - .stroke(colorScheme == .dark ? - Color.white.opacity(0.2) : - Color.black.opacity(0.1), - lineWidth: 1) - ) - ) - .shadow(color: Color.black.opacity(colorScheme == .dark ? 0.25 : 0.15), radius: 16, x: 0, y: 8) - .scaleEffect(scale) - .opacity(opacity) - } - .onAppear { - withAnimation(.spring(response: 0.25, dampingFraction: 0.7)) { - opacity = 1 - scale = 1 - } - } - } - - private func dismissWithAnimation() { - withAnimation(.easeOut(duration: 0.18)) { - opacity = 0 - scale = 0.95 - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) { - onDismiss() - } - } -} diff --git a/StikJIT/Views/DeviceLibraryView.swift b/StikJIT/Views/DeviceLibraryView.swift deleted file mode 100644 index e4e73621..00000000 --- a/StikJIT/Views/DeviceLibraryView.swift +++ /dev/null @@ -1,535 +0,0 @@ -// -// DeviceLibraryView.swift -// StikJIT -// -// Created by Stephen. -// - -import SwiftUI -import UniformTypeIdentifiers - -private struct DeviceAlert: Identifiable { - let id = UUID() - let title: String - let message: String - let isError: Bool -} - -private enum DeviceEditorMode: Identifiable { - case add - case edit(DeviceProfileEntry) - - var id: String { - switch self { - case .add: return "add" - case .edit(let device): return device.id.uuidString - } - } -} - -struct DeviceLibraryView: View { - @StateObject private var store = DeviceLibraryStore.shared - @State private var editorMode: DeviceEditorMode? - @State private var alert: DeviceAlert? - @State private var isActivatingDevice = false - - @AppStorage("customAccentColor") private var customAccentColorHex: String = "" - @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 { themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue } - - private var savedDevices: [DeviceProfileEntry] { - store.devices.sorted { lhs, rhs in - lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending - } - } - - private var displayedDevices: [DeviceProfileEntry] { - [store.defaultLocalDevice] + savedDevices - } - - private var activeSubtitle: String { - store.activeDevice != nil - ? "Currently using an external device." - : "No external device selected." - } - - var body: some View { - NavigationStack { - ZStack { - ThemedBackground(style: backgroundStyle) - .ignoresSafeArea() - - ScrollView { - VStack(spacing: 20) { - fullWidthCard { - VStack(alignment: .leading, spacing: 16) { - headerRow - activeSummaryCard - - ForEach(displayedDevices) { device in - let isDefault = store.isDefaultDevice(device) - DeviceRow(device: device, - isActive: isDefault ? !store.isUsingExternalDevice : store.activeDeviceID == device.id, - isDefault: isDefault, - accentColor: accentColor, - onActivate: { activate(device: device) }, - onEdit: isDefault ? nil : { editorMode = .edit(device) }, - onDelete: isDefault ? nil : { delete(device: device) }) - if device.id != displayedDevices.last?.id { - Divider() - } - } - - footerText - } - } - } - .padding(.horizontal, 20) - .padding(.vertical, 30) - } - } - .navigationTitle("Devices") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - editorMode = .add - } label: { - Label("Add Device", systemImage: "plus") - } - } - } - } - .tint(accentColor) - .preferredColorScheme(preferredScheme) - .disabled(isActivatingDevice) - .sheet(item: $editorMode) { mode in - DeviceEditorSheet(mode: mode) { input in - try handleSave(mode: mode, input: input) - if case .edit(let originalDevice) = mode, - store.activeDeviceID == originalDevice.id, - input.pairingData != nil { - if let refreshedDevice = store.devices.first(where: { $0.id == originalDevice.id }) { - do { - try store.activate(device: refreshedDevice) - startHeartbeatInBackground() - alert = DeviceAlert(title: "Pairing Updated", - message: "\(refreshedDevice.name)'s pairing file was refreshed.", - isError: false) - } catch { - alert = DeviceAlert(title: "Activation Failed", - message: error.localizedDescription, - isError: true) - } - } - } - } - } - .alert(item: $alert) { alert in - Alert( - title: Text(alert.title), - message: Text(alert.message), - dismissButton: .default(Text("OK")) - ) - } - } - - private func handleSave(mode: DeviceEditorMode, input: DeviceEditorInput) throws { - switch mode { - case .add: - try store.addDevice(name: input.name, - ipAddress: input.ipAddress, - pairingData: input.pairingData, - originalFilename: input.pairingFilename, - isTXM: input.isTXM) - alert = DeviceAlert(title: "Device Saved", message: "\(input.name) was added to your library.", isError: false) - case .edit(let device): - try store.update(device: device, - name: input.name, - ipAddress: input.ipAddress, - pairingData: input.pairingData, - originalFilename: input.pairingFilename ?? device.pairingFilename, - isTXM: input.isTXM) - alert = DeviceAlert(title: "Device Updated", message: "\(input.name) has been updated.", isError: false) - } - } - - private func activate(device: DeviceProfileEntry) { - guard !isActivatingDevice else { return } - isActivatingDevice = true - defer { isActivatingDevice = false } - let activatingDefault = store.isDefaultDevice(device) - do { - try store.activate(device: device) - startHeartbeatInBackground() - let title = activatingDefault ? "Local Device Active" : "Device Activated" - let message = activatingDefault - ? "Switched back to debugging on this device." - : "\(device.name) is now active. The heartbeat will refresh automatically." - alert = DeviceAlert(title: title, message: message, isError: false) - } catch { - alert = DeviceAlert(title: "Activation Failed", - message: error.localizedDescription, - isError: true) - } - } - - private func delete(device: DeviceProfileEntry) { - do { - try store.remove(device: device) - } catch { - alert = DeviceAlert(title: "Delete Failed", message: error.localizedDescription, isError: true) - } - } - - @ViewBuilder - private func fullWidthCard(@ViewBuilder _ content: () -> Content) -> some View { - appGlassCard { - content() - } - .frame(maxWidth: .infinity) - } - - private var headerRow: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .firstTextBaseline) { - VStack(alignment: .leading, spacing: 4) { - - Text("Device Library") - .font(.system(.title, design: .rounded).weight(.bold)) - } - Spacer() - } - - Text("Manage saved targets and switch between local or remote hardware.") - .font(.subheadline) - .foregroundColor(.secondary) - } - } - - private var activeSummaryCard: some View { - VStack(alignment: .leading, spacing: 12) { - Label { - Text(activeSubtitle) - .font(.subheadline) - .foregroundColor(.secondary) - } icon: { - Image(systemName: store.isUsingExternalDevice ? "antenna.radiowaves.left.and.right" : "iphone") - .foregroundColor(accentColor) - } - - if let active = store.activeDevice { - let relative = relativeDateFormatter.localizedString(for: active.lastUpdated, relativeTo: Date()) - VStack(alignment: .leading, spacing: 4) { - Text(active.name) - .font(.headline) - Text("IP: \(active.ipAddress)") - .font(.footnote) - .foregroundColor(.secondary) - Text("Synced \(relative).") - .font(.caption) - .foregroundColor(.secondary) - } - Button { - activate(device: store.defaultLocalDevice) - } label: { - Label("Switch to This Device", systemImage: "arrow.uturn.backward") - .font(.footnote.weight(.semibold)) - } - .frame(maxWidth: .infinity, alignment: .leading) - .buttonStyle(.borderedProminent) - .tint(accentColor) - } else { - Text("Using the on-device pairing file. Saved devices appear below for quick switching.") - .font(.footnote) - .foregroundColor(.secondary) - } - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.primary.opacity(0.05)) - ) - } - - private var footerText: some View { - Text("Saved devices keep their own pairing files so you can connect without copying them manually.") - .font(.footnote) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } - - private var relativeDateFormatter: RelativeDateTimeFormatter { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .full - return formatter - } -} - -private struct DeviceRow: View { - let device: DeviceProfileEntry - let isActive: Bool - let isDefault: Bool - let accentColor: Color - let onActivate: () -> Void - let onEdit: (() -> Void)? - let onDelete: (() -> Void)? - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(device.name) - .font(.headline) - if isActive { - Label("Active", systemImage: "checkmark.circle.fill") - .font(.caption.weight(.semibold)) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background(accentColor.opacity(0.15), in: Capsule()) - .foregroundColor(accentColor) - } - } - if isDefault { - Text("Loopback IP: \(device.ipAddress)") - .font(.subheadline) - .foregroundColor(.secondary) - Text("Uses the pairing file already stored on this device.") - .font(.footnote) - .foregroundColor(.secondary) - } else { - Text("IP: \(device.ipAddress)") - .font(.subheadline) - .foregroundColor(.secondary) - Text("Pairing: \(device.pairingFilename)") - .font(.footnote) - .foregroundColor(.secondary) - if device.isTXM { - Label("TXM Capable", systemImage: "shield.checkerboard") - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background(Color.green.opacity(0.15), in: Capsule()) - .foregroundColor(.green) - } else { - Label("Non-TXM", systemImage: "xmark.shield") - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background(Color.secondary.opacity(0.15), in: Capsule()) - .foregroundColor(.secondary) - } - } - } - Spacer() - if onEdit != nil || onDelete != nil { - Menu { - Button("Use Device", action: onActivate) - if let onEdit { - Button("Edit Details", action: onEdit) - } - if let onDelete { - Button(role: .destructive, action: onDelete) { - Text("Delete Device") - } - } - } label: { - Image(systemName: "ellipsis.circle") - .font(.title3) - .foregroundColor(.secondary) - } - } - } - - Button(action: onActivate) { - let title = isActive ? "Active" : (isDefault ? "Use Loopback" : "Use This Device") - HStack { - Image(systemName: isDefault ? "iphone" : "bolt.fill") - Text(title).fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .background(isActive ? Color.gray.opacity(0.2) : accentColor.opacity(0.15), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .foregroundColor(isActive ? .secondary : accentColor) - } - .disabled(isActive) - } - } -} - -// MARK: - Editor Sheet - -private struct DeviceEditorInput { - var name: String - var ipAddress: String - var pairingData: Data? - var pairingFilename: String? - var isTXM: Bool -} - -private struct DeviceEditorSheet: View { - let mode: DeviceEditorMode - let onSave: (DeviceEditorInput) throws -> Void - - @Environment(\.dismiss) private var dismiss - @AppStorage("customAccentColor") private var customAccentColorHex: String = "" - @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue - @Environment(\.themeExpansionManager) private var themeExpansion - - @State private var name: String - @State private var ipAddress: String - @State private var pairingData: Data? - @State private var selectedFilename: String? - @State private var errorMessage: String? - @State private var showImporter = false - @State private var isTXMDevice: Bool - - private var accentColor: Color { themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue } - private var requiresPairing: Bool { - if case .add = mode { return true } - return false - } - private var existingFilename: String? { - if case .edit(let device) = mode { - return device.pairingFilename - } - return nil - } - private var title: String { - switch mode { - case .add: return "New Device" - case .edit: return "Edit Device" - } - } - private var canSave: Bool { - !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - !ipAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - (!requiresPairing || pairingData != nil) - } - - init(mode: DeviceEditorMode, onSave: @escaping (DeviceEditorInput) throws -> Void) { - self.mode = mode - self.onSave = onSave - switch mode { - case .add: - _name = State(initialValue: "") - _ipAddress = State(initialValue: "10.7.0.1") - _isTXMDevice = State(initialValue: false) - case .edit(let device): - _name = State(initialValue: device.name) - _ipAddress = State(initialValue: device.ipAddress) - _isTXMDevice = State(initialValue: device.isTXM) - } - _selectedFilename = State(initialValue: nil) - _pairingData = State(initialValue: nil) - } - - var body: some View { - NavigationStack { - Form { - Section(header: Text("Details")) { - TextField("Display Name", text: $name) - .textInputAutocapitalization(.words) - TextField("Device IP", text: $ipAddress) - .keyboardType(.numbersAndPunctuation) - Toggle(isOn: $isTXMDevice) { - Text("TXM Capable") - } - .tint(accentColor) - Text("Enable if this device includes the Trusted Execution Monitor (TXM).") - .font(.caption) - .foregroundColor(.secondary) - } - - Section(header: Text("Pairing File")) { - Button { - showImporter = true - } label: { - Label("Select Pairing File", systemImage: "doc.badge") - } - .buttonStyle(.borderedProminent) - .tint(accentColor) - - if let filename = selectedFilename { - Text("Selected: \(filename)") - .font(.footnote) - .foregroundColor(.secondary) - } else if let existing = existingFilename { - Text("Current: \(existing)") - .font(.footnote) - .foregroundColor(.secondary) - } else { - Text("No file selected") - .font(.footnote) - .foregroundColor(.secondary) - } - } - - if let errorMessage { - Section { - Text(errorMessage) - .foregroundColor(.red) - } - } - } - .navigationTitle(title) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - } - ToolbarItem(placement: .confirmationAction) { - Button("Save", action: save) - .disabled(!canSave) - } - } - .fileImporter( - isPresented: $showImporter, - allowedContentTypes: [ - UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, - .propertyList - ], - allowsMultipleSelection: false - ) { result in - switch result { - case .success(let urls): - guard let url = urls.first else { return } - let accessing = url.startAccessingSecurityScopedResource() - defer { - if accessing { url.stopAccessingSecurityScopedResource() } - } - do { - pairingData = try Data(contentsOf: url) - selectedFilename = url.lastPathComponent - errorMessage = nil - } catch { - errorMessage = "Failed to read file: \(error.localizedDescription)" - } - case .failure(let error): - errorMessage = error.localizedDescription - } - } - } - .tint(accentColor) - } - - private func save() { - guard canSave else { return } - do { - try onSave(DeviceEditorInput( - name: name, - ipAddress: ipAddress, - pairingData: pairingData, - pairingFilename: selectedFilename ?? existingFilename, - isTXM: isTXMDevice - )) - dismiss() - } catch { - errorMessage = error.localizedDescription - } - } -} diff --git a/StikJIT/Views/DisplayView.swift b/StikJIT/Views/DisplayView.swift deleted file mode 100644 index 686a2e60..00000000 --- a/StikJIT/Views/DisplayView.swift +++ /dev/null @@ -1,1293 +0,0 @@ -// DisplayView.swift -// StikJIT -// -// Created by neoarz on 4/9/25. - -import SwiftUI -import UIKit -import UniformTypeIdentifiers - -// MARK: - Accent Color Picker (Glassy style) -struct AccentColorPicker: View { - @Binding var selectedColor: Color - - let colors: [Color] = [ - .blue, - .init(hex: "#7FFFD4")!, - .init(hex: "#50C878")!, - .red, - .init(hex: "#6A5ACD")!, - .init(hex: "#DA70D6")!, - .white, - .black - ] - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - Text("Accent Color") - .font(.headline) - .foregroundColor(.primary) - - LazyVGrid(columns: Array(repeating: .init(.flexible(), spacing: 12), count: 9), spacing: 12) { - ForEach(colors, id: \.self) { color in - Circle() - .fill(color) - .frame(width: 28, height: 28) - .overlay( - Circle().stroke(Color.white.opacity(0.1), lineWidth: 1) - ) - .overlay( - Circle().stroke(selectedColor == color ? Color.primary : .clear, lineWidth: 2) - ) - .onTapGesture { - selectedColor = color - } - } - - ColorPicker("", selection: $selectedColor) - .labelsHidden() - .frame(width: 28, height: 28) - .overlay( - Circle().stroke(Color.white.opacity(0.1), lineWidth: 1) - ) - .clipShape(Circle()) - } - .frame(maxWidth: .infinity) - } - .padding(20) - .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) - ) - ) - .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) - .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4) - } -} - -// MARK: - Display Settings View -struct DisplayView: View { - @AppStorage("username") private var username = "User" - @AppStorage("customAccentColor") private var customAccentColorHex: String = "" - @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue - @AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true - @State private var selectedAccentColor: Color = .blue - @Environment(\.colorScheme) private var colorScheme - @Environment(\.themeExpansionManager) private var themeExpansionOptional - @Environment(\.dismiss) private var dismiss - - @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 { - themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue - } - - private var tintColor: Color { - hasThemeExpansion ? selectedAccentColor : .blue - } - - private var selectedThemeIdentifier: String { appThemeRaw } - - private var selectedBuiltInTheme: AppTheme? { - AppTheme(rawValue: selectedThemeIdentifier) - } - - private var selectedCustomTheme: CustomTheme? { - themeExpansion?.customTheme(for: selectedThemeIdentifier) - } - - private var selectedThemeName: String { - if let custom = selectedCustomTheme { - return custom.name - } - return selectedBuiltInTheme?.displayName ?? "Theme" - } - - private var backgroundStyle: BackgroundStyle { - themeExpansion?.backgroundStyle(for: selectedThemeIdentifier) ?? AppTheme.system.backgroundStyle - } - - private var shouldShowThemeExpansionUpsell: Bool { - themeExpansion?.shouldShowThemeExpansionUpsell ?? true - } - - var body: some View { - NavigationStack { - ZStack { - ThemedBackground(style: backgroundStyle) - .ignoresSafeArea() - - ScrollView { - VStack(spacing: 20) { - usernameCard - if hasThemeExpansion { - accentCard - themeCard - customThemesSection - } else if shouldShowThemeExpansionUpsell { - // Accent preview remains above - accentPreview - - // Built-in + Custom themes previews with paywall centered on top - ZStack(alignment: .center) { - VStack(spacing: 20) { - themePreview - customThemesPreview - } - .zIndex(0) - themeExpansionUpsellCard - .frame(maxWidth: .infinity) // match other cards’ width - .zIndex(1) - } - } - jitOptionsCard - } - .padding(.horizontal, 20) - .padding(.vertical, 30) - } - - if justSaved { - VStack { - Spacer() - Text("Saved") - .font(.footnote.weight(.semibold)) - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(.ultraThinMaterial, in: Capsule()) - .overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 1)) - .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 3) - .transition(.move(edge: .bottom).combined(with: .opacity)) - .padding(.bottom, 30) - } - .animation(.easeInOut(duration: 0.25), value: justSaved) - } - } - .navigationTitle("Display") - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button("Close") { dismiss() } - } - } - .onAppear { - if !hasThemeExpansion, let manager = themeExpansion, manager.isCustomThemeIdentifier(appThemeRaw) { - appThemeRaw = AppTheme.system.rawValue - } - loadCustomAccentColor() - 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(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 - - private var usernameCard: some View { - appGlassCard { - VStack(alignment: .leading, spacing: 12) { - Text("Username") - .font(.title3) - .fontWeight(.semibold) - .foregroundColor(.primary) - - HStack { - TextField("Username", text: $username) - .font(.body) - .foregroundColor(.primary) - .padding(.vertical, 8) - - if !username.isEmpty { - Button(action: { - username = "" - showSavedToast() - }) { - Image(systemName: "xmark.circle.fill") - .foregroundColor(Color(UIColor.tertiaryLabel)) - .font(.system(size: 16)) - } - } - } - .padding(.horizontal, 12) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(Color.white.opacity(0.12), lineWidth: 1) - ) - } - } - } - - private var accentCard: some View { - appGlassCard { - VStack(alignment: .leading, spacing: 12) { - Text("Accent") - .font(.title3) - .fontWeight(.semibold) - .foregroundColor(.primary) - - AccentColorPicker(selectedColor: $selectedAccentColor) - - HStack(spacing: 12) { - Button { - if let hex = selectedAccentColor.toHex() { - customAccentColorHex = hex - } else { - customAccentColorHex = "" - } - showSavedToast() - } label: { - HStack { - Image(systemName: "checkmark.circle.fill") - Text("Save") - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(selectedAccentColor) - ) - .foregroundColor(selectedAccentColor.contrastText()) - } - - Button { - customAccentColorHex = "" - selectedAccentColor = .blue - showSavedToast() - } label: { - HStack { - Image(systemName: "arrow.uturn.backward.circle") - Text("Reset") - } - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color(UIColor.tertiarySystemBackground)) - ) - } - } - } - } - } - - private var themeExpansionUpsellCard: some View { - let isAppStore = themeExpansion?.isAppStoreBuild ?? true - let productLoaded = themeExpansion?.themeExpansionProduct != nil - return appGlassCard { - VStack(alignment: .leading, spacing: 14) { - Text("StikDebug Theme Expansion") - .font(.title3) - .fontWeight(.semibold) - .foregroundColor(.primary) - - if !isAppStore { - Text("Theme Expansion is coming soon on this store.") - .font(.body) - .foregroundColor(.secondary) - Text("For now, you can continue using the default theme.") - .font(.footnote) - .foregroundColor(.secondary) - } else { - 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.restorePurchases() } - } 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) - } - } - } - } - .task { - if let manager = themeExpansion, - manager.isAppStoreBuild, - !manager.isProcessing, - manager.themeExpansionProduct == nil, - manager.lastError == nil { - await manager.refreshEntitlements() - } - } - } - - private var jitOptionsCard: some View { - appGlassCard { - VStack(alignment: .leading, spacing: 12) { - Text("App List") - .font(.title3) - .fontWeight(.semibold) - .foregroundColor(.primary) - - VStack(alignment: .leading, spacing: 6) { - Toggle("Load App Icons", isOn: $loadAppIconsOnJIT) - .tint(accentColor) - - Text("Disabling this will hide app icons in the app list and may improve performance, while also giving it a more minimalistic look.") - .font(.footnote) - .foregroundColor(.secondary) - } - } - } - } - - private var themeCard: some View { - appGlassCard { - VStack(alignment: .leading, spacing: 16) { - Text("Themes") - .font(.title3.weight(.semibold)) - .foregroundColor(.primary) - - selectedThemePreview - Divider() - builtInThemesGrid(interactive: hasThemeExpansion, locked: !hasThemeExpansion) - } - } - } - - private var selectedThemePreview: some View { - ThemePreviewCard(style: backgroundStyle, - title: selectedThemeName, - selected: true, - action: {}, - staticPreview: false, - allowsInteraction: false, - height: 160) - .accessibilityHidden(true) - } - - private var themeLockedPreviewCard: some View { - appGlassCard { - VStack(alignment: .leading, spacing: 16) { - Text("Themes") - .font(.title3.weight(.semibold)) - .foregroundColor(.primary) - builtInThemesGrid(interactive: false, locked: true) - } - } - } - - private var gridColumns: [GridItem] { - Array(repeating: GridItem(.flexible(), spacing: 10), count: 2) - } - - @ViewBuilder - private func builtInThemesGrid(interactive: Bool, locked: Bool) -> some View { - LazyVGrid(columns: gridColumns, spacing: 12) { - ForEach(AppTheme.allCases, id: \.self) { theme in - let isSelected = selectedBuiltInTheme == theme && selectedCustomTheme == nil - ThemeOptionTile(style: theme.backgroundStyle, - title: theme.displayName, - isSelected: isSelected, - isLocked: locked, - interactive: interactive) { - guard hasThemeExpansion else { return } - appThemeRaw = theme.rawValue - applyThemePreferences() - showSavedToast() - } - } - } - } - @ViewBuilder - private var customThemesSection: some View { - if hasThemeExpansion, let manager = themeExpansion { - appGlassCard { - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("Custom Themes") - .font(.title3.weight(.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: gridColumns, spacing: 12) { - ForEach(manager.customThemes, id: \.id) { theme in - let identifier = manager.customThemeIdentifier(for: theme) - let isSelected = selectedCustomTheme?.id == theme.id - ThemeOptionTile(style: manager.backgroundStyle(for: identifier), - title: theme.name, - isSelected: isSelected, - isLocked: false, - interactive: true) { - 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() - } - } - } - } - } - } - } - } - } - } - - // MARK: - Helpers - - private func loadCustomAccentColor() { - selectedAccentColor = themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue - } - - 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 - } - } - - private func showSavedToast() { - withAnimation { justSaved = true } - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - withAnimation { justSaved = false } - } - } - - // MARK: - Paywall previews (optimized look, non-interactive) - - private func lockedPreview(_ content: Content) -> some View { - content - // Hardware-optimized blur via system material over the content - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(.ultraThinMaterial) - .opacity(0.4) // more transparent blur for a stronger preview - ) - // Slight dim to improve contrast and preserve the “locked” feel - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(Color.black.opacity(0.03)) // lighter dim for more visibility - ) - .overlay(alignment: .topLeading) { - HStack(spacing: 6) { - Image(systemName: "lock.fill") - Text("Preview") - .fontWeight(.semibold) - } - .font(.caption2) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(.ultraThinMaterial, in: Capsule()) - .overlay(Capsule().stroke(Color.white.opacity(0.15), lineWidth: 1)) - .padding(12) - } - .compositingGroup() // flatten for better GPU compositing - .allowsHitTesting(false) - } - - // Use static theme grid inside the locked preview to avoid animation cost - private var accentPreview: some View { lockedPreview(accentCard) } - private var themePreview: some View { lockedPreview(themeLockedPreviewCard) } - - private var customThemesPreview: some View { - lockedPreview(customThemesPreviewCard) - } - - private var customThemesPreviewCard: some View { - appGlassCard { - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("Custom Themes") - .font(.title3.weight(.semibold)) - .foregroundColor(.primary) - Spacer() - Label("New", systemImage: "plus.circle.fill") - .font(.subheadline.weight(.semibold)) - .opacity(0.6) - } - - LazyVGrid(columns: gridColumns, spacing: 12) { - ThemeOptionTile(style: .customGradient(colors: [Color(hex: "#3E4C7C") ?? .indigo, - Color(hex: "#1C1F3A") ?? .blue]), - title: "Midnight Fade", - isSelected: false, - isLocked: true, - interactive: false, - action: {}) - - ThemeOptionTile(style: .customGradient(colors: [Color(hex: "#00F5A0") ?? .green, - Color(hex: "#00D9F5") ?? .cyan, - Color(hex: "#C96BFF") ?? .purple]), - title: "Neon Drift", - isSelected: false, - isLocked: true, - interactive: false, - action: {}) - } - } - } - } -} - -// MARK: - Theme Option Tile & Preview Card - -private struct ThemeOptionTile: View { - @Environment(\.colorScheme) private var colorScheme - - let style: BackgroundStyle - let title: String - let isSelected: Bool - let isLocked: Bool - let interactive: Bool - let action: () -> Void - - private var borderColor: Color { - if isSelected { return .accentColor } - if isLocked { return Color.black.opacity(0.08) } - return Color.black.opacity(0.12) - } - - var body: some View { - let tile = ZStack(alignment: .bottomLeading) { - ThemePreviewThumbnail(style: style, - colorScheme: colorScheme) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.black.opacity(0.12)) - ) - - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.footnote.weight(.semibold)) - .foregroundColor(.white) - if isLocked { - Text("Locked") - .font(.caption2.weight(.semibold)) - .foregroundColor(.white.opacity(0.8)) - } - } - Spacer() - if isLocked { - Image(systemName: "lock.fill") - .foregroundColor(.white.opacity(0.85)) - .font(.caption.weight(.bold)) - } else if isSelected { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.white) - .font(.title3.weight(.bold)) - } - } - .padding(12) - } - .frame(height: 110) - .frame(maxWidth: .infinity) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(borderColor, lineWidth: isSelected ? 2 : 1) - ) - .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2) - .opacity(isLocked ? 0.85 : 1) - - if interactive && !isLocked { - Button(action: action) { - tile - } - .buttonStyle(.plain) - } else { - tile - } - } -} - -private struct ThemePreviewCard: View { - let style: BackgroundStyle - let title: String - let selected: Bool - let action: () -> Void - var staticPreview: Bool = false - var allowsInteraction: Bool = true - var height: CGFloat = 120 - - @Environment(\.colorScheme) private var colorScheme - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - private func staticized(_ style: BackgroundStyle) -> BackgroundStyle { - switch style { - case .staticGradient(let colors): - return .staticGradient(colors: colors) - case .animatedGradient(let colors, _): - return .staticGradient(colors: colors) - case .blobs(_, let background): - // Use the background gradient for a static look - return .staticGradient(colors: background) - case .particles(_, let background): - // Use the background gradient for a static look - return .staticGradient(colors: background) - case .customGradient(let colors): - return .customGradient(colors: colors) - case .adaptiveGradient(let light, let dark): - let colors = colorScheme == .dark ? dark : light - return .staticGradient(colors: colors) - } - } - - var body: some View { - Group { - if allowsInteraction { - Button(action: action) { - cardBody - } - .buttonStyle(.plain) - } else { - cardBody - } - } - } - - private var cardBody: some View { - ZStack { - backgroundContent - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(.ultraThinMaterial) - .padding(6) - .opacity(0.55) - ) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - - VStack(spacing: 6) { - Text(title) - .font(.footnote.weight(.semibold)) - .foregroundColor(.primary) - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background(.ultraThinMaterial, in: Capsule()) - } - .padding(8) - } - .frame(height: height) - .overlay( - 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) - } - - private var backgroundContent: some View { - Group { - if staticPreview { - ThemePreviewThumbnail(style: staticized(style), - colorScheme: colorScheme) - } else { - UIKitThemeBackground(style: style, - reduceMotion: reduceMotion, - colorScheme: colorScheme) - } - } - } -} - -// MARK: - UIKit-powered background previews - -private struct UIKitThemeBackground: UIViewRepresentable { - let style: BackgroundStyle - let reduceMotion: Bool - let colorScheme: ColorScheme - - func makeUIView(context: Context) -> ThemePreviewUIKitView { - ThemePreviewUIKitView() - } - - func updateUIView(_ uiView: ThemePreviewUIKitView, context: Context) { - uiView.configure(style: style, - reduceMotion: reduceMotion, - interfaceStyle: colorScheme) - } -} - -private final class ThemePreviewUIKitView: UIView { - private let gradientLayer = CAGradientLayer() - private var emitterLayer: CAEmitterLayer? - private var currentConfigurationKey: String? - - override init(frame: CGRect) { - super.init(frame: frame) - clipsToBounds = true - layer.cornerCurve = .continuous - layer.cornerRadius = 16 - gradientLayer.startPoint = CGPoint(x: 0, y: 0) - gradientLayer.endPoint = CGPoint(x: 1, y: 1) - layer.addSublayer(gradientLayer) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func layoutSubviews() { - super.layoutSubviews() - gradientLayer.frame = bounds - emitterLayer?.emitterPosition = CGPoint(x: bounds.midX, y: bounds.midY) - emitterLayer?.emitterSize = bounds.size - } - - func configure(style: BackgroundStyle, reduceMotion: Bool, interfaceStyle: ColorScheme) { - let key = configurationKey(for: style, - reduceMotion: reduceMotion, - interfaceStyle: interfaceStyle) - guard key != currentConfigurationKey else { return } - currentConfigurationKey = key - gradientLayer.removeAllAnimations() - emitterLayer?.removeFromSuperlayer() - emitterLayer = nil - - switch style { - case .staticGradient(let colors): - applyGradient(colors: colors) - case .animatedGradient(let colors, let speed): - applyAnimatedGradient(colors: colors, speed: speed, reduceMotion: reduceMotion) - case .blobs(_, let background): - // UIKit snapshot of blobs can be heavy; fall back to background gradient for previews. - applyGradient(colors: background) - case .particles(let particle, let background): - applyGradient(colors: background) - applyParticleOverlay(color: particle, reduceMotion: reduceMotion) - case .customGradient(let colors): - applyGradient(colors: colors) - case .adaptiveGradient(let light, let dark): - let palette = interfaceStyle == .dark ? dark : light - applyGradient(colors: palette) - } - } - - private func applyGradient(colors: [Color]) { - gradientLayer.colors = colors.nonEmptyOrFallback().map { UIColor($0).cgColor } - } - - private func applyAnimatedGradient(colors: [Color], speed: Double, reduceMotion: Bool) { - applyGradient(colors: colors) - guard !reduceMotion else { return } - - let duration = max(8.0, 18.0 / max(speed, 0.02)) - let startAnimation = CABasicAnimation(keyPath: "startPoint") - startAnimation.fromValue = CGPoint(x: 0, y: 0) - startAnimation.toValue = CGPoint(x: 1, y: 1) - startAnimation.duration = duration - startAnimation.autoreverses = true - startAnimation.repeatCount = .infinity - startAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) - - let endAnimation = CABasicAnimation(keyPath: "endPoint") - endAnimation.fromValue = CGPoint(x: 1, y: 1) - endAnimation.toValue = CGPoint(x: 0, y: 0) - endAnimation.duration = duration - endAnimation.autoreverses = true - endAnimation.repeatCount = .infinity - endAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) - - gradientLayer.add(startAnimation, forKey: "startPoint") - gradientLayer.add(endAnimation, forKey: "endPoint") - } - - private func applyParticleOverlay(color: Color, reduceMotion: Bool) { - guard !reduceMotion else { return } - let emitter = CAEmitterLayer() - emitter.emitterShape = .rectangle - emitter.emitterMode = .surface - emitter.renderMode = .additive - emitter.emitterCells = [makeParticleCell(color: color)] - layer.addSublayer(emitter) - emitterLayer = emitter - setNeedsLayout() - } - - private func makeParticleCell(color: Color) -> CAEmitterCell { - let cell = CAEmitterCell() - cell.birthRate = 25 - cell.lifetime = 18 - cell.velocity = 12 - cell.velocityRange = 8 - cell.scale = 0.015 - cell.scaleRange = 0.01 - cell.alphaSpeed = -0.02 - cell.contents = particleImage(color: color).cgImage - return cell - } - - private func particleImage(color: Color) -> UIImage { - let size: CGFloat = 6 - let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size)) - return renderer.image { ctx in - let rect = CGRect(x: 0, y: 0, width: size, height: size) - ctx.cgContext.setFillColor(UIColor(color).withAlphaComponent(0.9).cgColor) - ctx.cgContext.fillEllipse(in: rect) - } - } - - private func configurationKey(for style: BackgroundStyle, - reduceMotion: Bool, - interfaceStyle: ColorScheme) -> String { - let schemeKey = interfaceStyle == .dark ? "dark" : "light" - return "\(style.previewIdentityKey(for: interfaceStyle))|motion:\(reduceMotion)|scheme:\(schemeKey)" - } -} - -private extension Array where Element == Color { - func nonEmptyOrFallback() -> [Color] { - if isEmpty { return [Color.blue, Color.purple] } - if count == 1 { return [self[0], self[0].opacity(0.7)] } - return self - } - - func previewIdentityKey() -> String { - map { $0.previewIdentityKey }.joined(separator: ",") - } -} - -private extension Color { - var previewIdentityKey: String { - if let hex = toHex() { - return hex - } - return String(describing: self) - } -} - -private extension BackgroundStyle { - func previewIdentityKey(for scheme: ColorScheme) -> String { - switch self { - case .staticGradient(let colors): - return "static:\(colors.previewIdentityKey())" - case .animatedGradient(let colors, let speed): - return "animated:\(String(format: "%.4f", speed)):\(colors.previewIdentityKey())" - case .blobs(let colors, let background): - return "blobs:\(colors.previewIdentityKey())|bg:\(background.previewIdentityKey())" - case .particles(let particle, let background): - return "particles:\(particle.previewIdentityKey)|bg:\(background.previewIdentityKey())" - case .customGradient(let colors): - return "custom:\(colors.previewIdentityKey())" - case .adaptiveGradient(let light, let dark): - let palette = scheme == .dark ? dark : light - return "adaptive:\(palette.previewIdentityKey())" - } - } - - func thumbnailColors(for scheme: ColorScheme) -> [Color] { - switch self { - case .staticGradient(let colors): - return colors - case .animatedGradient(let colors, _): - return colors - case .blobs(_, let background): - return background - case .particles(_, let background): - return background - case .customGradient(let colors): - return colors - case .adaptiveGradient(let light, let dark): - return scheme == .dark ? dark : light - } - } -} - -private struct ThemePreviewThumbnail: View { - let style: BackgroundStyle - let colorScheme: ColorScheme - var cornerRadius: CGFloat = 16 - @State private var image: UIImage? - - private var cacheKey: String { - style.previewIdentityKey(for: colorScheme) - } - - var body: some View { - ZStack { - if let image { - Image(uiImage: image) - .resizable() - .scaledToFill() - } else { - placeholderGradient - } - } - .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) - .task(id: cacheKey) { - image = await ThemePreviewThumbnailCache.shared.image(for: style, - scheme: colorScheme) - } - } - - private var placeholderGradient: some View { - LinearGradient(colors: style.thumbnailColors(for: colorScheme).nonEmptyOrFallback(), - startPoint: .topLeading, - endPoint: .bottomTrailing) - } -} - -private final class ThemePreviewThumbnailCache { - static let shared = ThemePreviewThumbnailCache() - private let cache = NSCache() - private let queue = DispatchQueue(label: "ThemePreviewThumbnailCache", - qos: .userInitiated) - private let renderSize = CGSize(width: 320, height: 200) - - func image(for style: BackgroundStyle, scheme: ColorScheme) async -> UIImage { - let key = style.previewIdentityKey(for: scheme) as NSString - if let cached = cache.object(forKey: key) { - return cached - } - - return await withCheckedContinuation { continuation in - queue.async { - let image = self.drawThumbnail(style: style, scheme: scheme) - self.cache.setObject(image, forKey: key) - continuation.resume(returning: image) - } - } - } - - private func drawThumbnail(style: BackgroundStyle, scheme: ColorScheme) -> UIImage { - let colors = style.thumbnailColors(for: scheme).nonEmptyOrFallback() - let uiColors = colors.map { UIColor($0) } - let renderer = UIGraphicsImageRenderer(size: renderSize) - return renderer.image { ctx in - guard let gradient = CGGradient( - colorsSpace: CGColorSpaceCreateDeviceRGB(), - colors: uiColors.map { $0.cgColor } as CFArray, - locations: nil - ) else { - ctx.cgContext.setFillColor(uiColors.first?.cgColor ?? UIColor.systemBackground.cgColor) - ctx.cgContext.fill(CGRect(origin: .zero, size: renderSize)) - return - } - ctx.cgContext.drawLinearGradient( - gradient, - start: CGPoint(x: 0, y: 0), - end: CGPoint(x: renderSize.width, y: renderSize.height), - options: [] - ) - } - } -} - -// 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 - } - } - } -} diff --git a/StikJIT/Views/HomeView.swift b/StikJIT/Views/HomeView.swift index b3bfde0a..3b73c9a7 100644 --- a/StikJIT/Views/HomeView.swift +++ b/StikJIT/Views/HomeView.swift @@ -7,11 +7,6 @@ import SwiftUI import UniformTypeIdentifiers -import Pipify -import UIKit -import WidgetKit -import Combine -import Network struct JITEnableConfiguration { var bundleID: String? = nil @@ -21,251 +16,234 @@ struct JITEnableConfiguration { } struct HomeView: View { - + @AppStorage("username") private var username = "User" - @AppStorage("customAccentColor") private var customAccentColorHex: String = "" - @Environment(\.colorScheme) private var colorScheme - @Environment(\.accentColor) private var environmentAccentColor + @AppStorage("autoQuitAfterEnablingJIT") private var doAutoQuitAfterEnablingJIT = false 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 - @State private var pairingFileExists: Bool = true - @State private var pairingFilePresentOnDisk: Bool = true - @State private var isValidatingPairingFile = false - @State private var lastValidatedPairingSignature: PairingFileSignature? = nil + @State private var pairingFileExists: Bool = 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 showPIDSheet = false - @AppStorage("recentPIDs") private var recentPIDs: [Int] = [] - @State private var justCopied = false - @State private var viewDidAppeared = false @State private var pendingJITEnableConfiguration : JITEnableConfiguration? = nil - @AppStorage("enableAdvancedOptions") private var enableAdvancedOptions = false - @AppStorage("enablePiP") private var enablePiP = true @State var scriptViewShow = false - @State private var pipRequired = false + @State private var isShowingConsole = false @AppStorage("DefaultScriptName") var selectedScript = "attachDetach.js" @State var jsModel: RunJSViewModel? - @ObservedObject private var mounting = MountingProgress.shared - @ObservedObject private var deviceStore = DeviceLibraryStore.shared - @State private var heartbeatOK = false - @State private var cachedAppNames: [String: String] = [:] - @AppStorage("pinnedSystemApps") private var pinnedSystemApps: [String] = [] - @AppStorage("pinnedSystemAppNames") private var pinnedSystemAppNames: [String: String] = [:] - @State private var launchingSystemApps: Set = [] - @State private var systemLaunchMessage: String? = nil - @State private var connectionCheckState: ConnectionCheckState = .idle - @State private var connectionInfoMessage: String? = nil - @State private var hasAutoStartedConnectionCheck = false - @State private var connectionTimeoutTask: DispatchWorkItem? = nil - @State private var wifiConnected = false - @State private var wifiMonitor: NWPathMonitor? = nil - @State private var isCellularActive = false - @State private var cellularMonitor: NWPathMonitor? = nil - @State private var isSchedulingInitialSetup = false - @AppStorage("cachedAppNamesData") private var cachedAppNamesData: Data? - - @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 { - themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue - } - - private var ddiMounted: Bool { true } - private var canConnectByApp: Bool { pairingFileExists && ddiMounted } - private var requiresLoopbackVPN: Bool { DeviceConnectionContext.requiresLoopbackVPN } - private var pairingFileLikelyInvalid: Bool { false } - private var sanitizedUsername: String { - let trimmed = username.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? "there" : trimmed - } - private var greetingTitle: String { - "\(timeOfDayGreeting), \(sanitizedUsername)!" - } - private var greetingSubtitle: String { - if canConnectByApp { - return "You're all set. Connect whenever you're ready." - } else if !pairingFileExists { - return "Import your pairing file to start debugging." - } else if !ddiMounted { - return "Mount the DDI to finish preparing your device." - } - return "Complete the steps below to get ready." - } - private var timeOfDayGreeting: String { - let hour = Calendar.current.component(.hour, from: Date()) - switch hour { - case 5..<12: return "Good morning" - case 12..<17: return "Good afternoon" - case 17..<22: return "Good evening" - default: return "Hello" - } - } - private var shouldPromptForWiFi: Bool { false } - - private let pairingFileURL = URL.documentsDirectory.appendingPathComponent("pairingFile.plist") - - @ViewBuilder - private var homeContent: some View { - VStack(spacing: 20) { - welcomeCard - heartbeatCard - connectCard - // if pairingFileExists { - // quickConnectCard - // } - if !pinnedLaunchItems.isEmpty { - launchShortcutsCard - } - tipsCard - } - .padding(.horizontal, 20) - .padding(.vertical, 30) - } - + + var body: some View { - NavigationStack { - ZStack { - ThemedBackground(style: backgroundStyle) - .ignoresSafeArea() - - ScrollView { - homeContent - } - .scrollIndicators(.hidden) - - if isImportingFile { - Color.black.opacity(0.35).ignoresSafeArea() - ProgressView("Processing pairing file…") - .padding(16) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) - ) - ) - .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) + ZStack { + VStack(spacing: 25) { + Spacer() + VStack(spacing: 5) { + Text("Welcome to StikDebug \(username)!") + .font(.system(.largeTitle, design: .rounded)) + .fontWeight(.bold) + .lineLimit(1) + .minimumScaleFactor(0.5) + + Text(pairingFileExists ? "Click enable JIT to get started" : "Pick pairing file to get started") + .font(.system(.subheadline, design: .rounded)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) } + .padding(.top, 40) - if showPairingFileMessage && pairingFileIsValid && !isImportingFile { - toast("✓ Pairing file successfully imported") + // Main action button - changes based on whether we have a pairing file + Button(action: { + + + if pairingFileExists { + // Refresh heartbeat + pubHeartBeat = false + startHeartbeatInBackground() + + // Got a pairing file, check mount status + let mountStatus = checkMountStatus() + if mountStatus == .notMounted { + showAlert(title: "Device Not Mounted", message: "The Developer Disk Image has not been mounted yet. Check in settings for more information.", showOk: true) { cool in + // No Need + } + return + } else if mountStatus == .unreachable { + // Don't show a separate error here — the heartbeat + // will fail and show its own connectivity error. + return + } + + isShowingInstalledApps = true + + } else { + // No pairing file yet, let's get one + isShowingPairingFilePicker = true + } + }) { + HStack { + Image(systemName: pairingFileExists ? "bolt.fill" : "doc.badge.plus") + .font(.system(size: 20)) + Text(pairingFileExists ? "Enable JIT" : "Select Pairing File") + .font(.system(.title3, design: .rounded)) + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(16) } - if justCopied { - toast("Copied") + .padding(.horizontal, 20) + + Button(action: { + isShowingConsole = true + }) { + HStack { + Image(systemName: "terminal") + .font(.system(size: 20)) + Text("Open Console") + .font(.system(.title3, design: .rounded)) + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.gray.opacity(0.2)) + .foregroundColor(.primary) + .cornerRadius(16) } - if let message = systemLaunchMessage { - toast(message) + .padding(.horizontal, 20) + + // Status message area - keeps layout consistent + ZStack { + // Progress bar for importing file + if isImportingFile { + VStack(spacing: 8) { + HStack { + Text("Processing pairing file...") + .font(.system(.caption, design: .rounded)) + Spacer() + Text("\(Int(importProgress * 100))%") + .font(.system(.caption, design: .rounded)) + } + + GeometryReader { geometry in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.black.opacity(0.2)) + .frame(height: 8) + + RoundedRectangle(cornerRadius: 4) + .fill(Color.green) + .frame(width: geometry.size.width * CGFloat(importProgress), height: 8) + .animation(.linear(duration: 0.3), value: importProgress) + } + } + .frame(height: 8) + } + .padding(.horizontal, 40) + } + + // Success message + if showPairingFileMessage && pairingFileIsValid { + Text("✓ Pairing file successfully imported") + .font(.system(.callout, design: .rounded)) + .foregroundColor(.green) + .padding(.vertical, 4) + .padding(.horizontal, 12) + .background(Color.green.opacity(0.1)) + .cornerRadius(8) + .transition(.opacity) + } + + // Invisible text to reserve space - no layout jumps + Text(" ").opacity(0) } + .frame(height: isImportingFile ? 60 : 30) // Adjust height based on what's showing + + Spacer() } - .navigationTitle("Home") + .padding() } - .preferredColorScheme(preferredScheme) .onAppear { - scheduleInitialSetupWork() - startWiFiMonitoring() - startCellularMonitoring() - if !hasAutoStartedConnectionCheck { - hasAutoStartedConnectionCheck = true - runConnectionDiagnostics(autoStart: true) - } + checkPairingFileExists() startHeartbeatInBackground() - NotificationCenter.default.addObserver( - forName: NSNotification.Name("ShowPairingFilePicker"), - object: nil, - queue: .main - ) { _ in isShowingPairingFilePicker = true } - } - .onDisappear { - connectionTimeoutTask?.cancel() - connectionTimeoutTask = nil - stopWiFiMonitoring() - stopCellularMonitoring() - hasAutoStartedConnectionCheck = false + MountingProgress.shared.checkforMounted() } .onReceive(timer) { _ in - refreshBackground() checkPairingFileExists() - heartbeatOK = pubHeartBeat if mounting.mountingThread == nil && !mounting.coolisMounted { MountingProgress.shared.checkforMounted() } } - .onChange(of: pairingFileExists) { _, newValue in - if newValue { - loadAppListIfNeeded(force: cachedAppNames.isEmpty) - runConnectionDiagnostics() - } else { - cachedAppNames = [:] - } - } - .onChange(of: favoriteApps) { _, _ in - loadAppListIfNeeded() - syncFavoriteAppNamesWithCache() - } - .onChange(of: recentApps) { _, _ in - loadAppListIfNeeded() - } - .fileImporter(isPresented: $isShowingPairingFilePicker, allowedContentTypes: [UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, .propertyList]) { result in + .fileImporter(isPresented: $isShowingPairingFilePicker, allowedContentTypes: [UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, UTType(filenameExtension: "mobiledevicepair", conformingTo: .data)!, .propertyList]) {result in switch result { + case .success(let url): let fileManager = FileManager.default let accessing = url.startAccessingSecurityScopedResource() if fileManager.fileExists(atPath: url.path) { do { - let dest = URL.documentsDirectory.appendingPathComponent("pairingFile.plist") - if FileManager.default.fileExists(atPath: dest.path) { - try fileManager.removeItem(at: dest) + if fileManager.fileExists(atPath: URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path) { + try fileManager.removeItem(at: URL.documentsDirectory.appendingPathComponent("pairingFile.plist")) } - try fileManager.copyItem(at: url, to: dest) + try fileManager.copyItem(at: url, to: URL.documentsDirectory.appendingPathComponent("pairingFile.plist")) + print("File copied successfully!") + + // Show progress bar and initialize progress DispatchQueue.main.async { isImportingFile = true - importProgress = 0 + importProgress = 0.0 pairingFileExists = true } - DispatchQueue.main.async { - startHeartbeatInBackground() - } + startHeartbeatInBackground() - let progressTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { t in + let progressTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { timer in DispatchQueue.main.async { - if importProgress < 1 { + if importProgress < 1.0 { importProgress += 0.25 } else { - t.invalidate() + timer.invalidate() isImportingFile = false pairingFileIsValid = true - withAnimation { showPairingFileMessage = true } - MountingProgress.shared.checkforMounted() - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - withAnimation { showPairingFileMessage = false } + + // Show success message + withAnimation { + showPairingFileMessage = true + } + + // Hide message after delay + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + withAnimation { + showPairingFileMessage = false + } } } } } + + // Ensure timer keeps running RunLoop.current.add(progressTimer, forMode: .common) + } catch { print("Error copying file: \(error)") } + } else { + print("Source file does not exist.") + } + + if accessing { + url.stopAccessingSecurityScopedResource() } - if accessing { url.stopAccessingSecurityScopedResource() } case .failure(let error): print("Failed to import file: \(error)") } @@ -275,54 +253,11 @@ struct HomeView: View { bundleID = selectedBundle isShowingInstalledApps = false HapticFeedbackHelper.trigger() - - var autoScriptData: Data? = nil - var autoScriptName: String? = nil - - if let scriptInfo = preferredScript(for: selectedBundle) { - autoScriptData = scriptInfo.data - autoScriptName = scriptInfo.name - } - - startJITInBackground(bundleID: selectedBundle, - pid: nil, - scriptData: autoScriptData, - scriptName: autoScriptName, - triggeredByURLScheme: false) - } - } - .pipify(isPresented: Binding( - get: { pipRequired && enablePiP }, - set: { pipRequired = $0 } - )) { - RunJSViewPiP(model: $jsModel) - } - .sheet(isPresented: $scriptViewShow) { - NavigationView { - if let jsModel { - RunJSView(model: jsModel) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { scriptViewShow = false } - } - } - .navigationTitle(selectedScript) - .navigationBarTitleDisplayMode(.inline) - } + startJITInBackground(bundleID: selectedBundle) } } - .sheet(isPresented: $showPIDSheet) { - ConnectByPIDSheet( - recentPIDs: $recentPIDs, - onPasteCopyToast: { showCopiedToast() }, - onConnect: { pid in - HapticFeedbackHelper.trigger() - startJITInBackground(pid: pid) - } - ) - } .onOpenURL { url in - guard let host = url.host else { return } + guard let host = url.host() else { return } let components = URLComponents(url: url, resolvingAgainstBaseURL: false) switch host { case "enable-jit": @@ -356,1653 +291,212 @@ struct HomeView: View { if let bundleId = components?.queryItems?.first(where: { $0.name == "bundle-id" })?.value { HapticFeedbackHelper.trigger() DispatchQueue.global(qos: .userInitiated).async { - let success = JITEnableContext.shared.launchAppWithoutDebug(bundleId, logger: nil) - DispatchQueue.main.async { - let nameRaw = pinnedSystemAppNames[bundleId] ?? friendlyName(for: bundleId) - let name = shortDisplayName(from: nameRaw) - systemLaunchMessage = success - ? String(format: "Launch requested: %@".localized, name) - : String(format: "Failed to launch %@".localized, name) - scheduleSystemToastDismiss() - } + let _ = JITEnableContext.shared.launchAppWithoutDebug(bundleId, logger: nil) } } default: break } } - .onAppear { + .onAppear() { viewDidAppeared = true if let config = pendingJITEnableConfiguration { startJITInBackground(bundleID: config.bundleID, pid: config.pid, scriptData: config.scriptData, scriptName: config.scriptName, triggeredByURLScheme: true) pendingJITEnableConfiguration = nil } } - } - - // MARK: - Styled Sections - - private var welcomeCard: some View { - homeCard { - VStack(alignment: .leading, spacing: 6) { - Text(greetingTitle) - .font(.system(.title2, design: .rounded).weight(.semibold)) - .foregroundStyle(.primary) - Text(greetingSubtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } - - private var heartbeatCard: some View { - homeCard { - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text("Heartbeat") - .font(.headline.weight(.semibold)) - Text(heartbeatSubtitle) - .font(.footnote) - .foregroundStyle(.secondary) - HStack(spacing: 6) { - pillIconButton(icon: "play.fill", title: "Start") { - startHeartbeatInBackground() - } - pillIconButton(icon: "arrow.clockwise", title: "Restart") { - pubHeartBeat = false - heartbeatOK = false - startHeartbeatInBackground() + .sheet(isPresented: $isShowingConsole) { + NavigationStack { + ConsoleLogsView() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { + isShowingConsole = false + } } } - } - Spacer(minLength: 0) - VStack(alignment: .trailing, spacing: 6) { - heartbeatStatusBadge - statusLightsRow - } } } - } - - private var connectCard: some View { - homeCard { - VStack(alignment: .leading, spacing: 16) { - Text("Connect") - .font(.headline.weight(.semibold)) - .foregroundStyle(.primary) - - if shouldPromptForWiFi { - statusBadge( - icon: "wifi.slash", - text: "Wi-Fi required", - color: .orange - ) - } - - primaryActionControls - - if let info = connectionInfoMessage, !info.isEmpty { - Text(info) - .font(.caption) - .foregroundStyle(.secondary) - } - - if isImportingFile { - pairingImportProgressView - } else if showPairingFileMessage && pairingFileIsValid { - pairingSuccessMessage + .sheet(isPresented: $scriptViewShow) { + NavigationStack { + if let jsModel { + RunJSView(model: jsModel) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { scriptViewShow = false } + } + } + .navigationTitle(selectedScript) + .navigationBarTitleDisplayMode(.inline) } } } } - // MARK: - Connection Setup Helpers - - @ViewBuilder - private var connectionStatusBadge: some View { - HStack { - Button { - refreshStatusTapped() - } label: { - iconOnlyStatusBadge(icon: "arrow.clockwise", text: "", color: .blue) - }.disabled(isConnectionCheckRunning) - if isConnectionCheckRunning { - statusBadge(icon: "clock.arrow.circlepath", text: "Checking…", color: .orange) - } else if allStatusIndicatorsGreen { - statusBadge(icon: "checkmark.circle.fill", text: "Ready", color: .green) - } else if connectionHasError { - statusBadge(icon: "exclamationmark.triangle.fill", text: "Needs attention", color: .yellow) - } else { - statusBadge(icon: "circle.lefthalf.filled", text: "Not ready", color: .yellow) - } - } - } - - @ViewBuilder - private var heartbeatStatusBadge: some View { - switch heartbeatIndicatorStatus { - case .success: - statusBadge(icon: "checkmark.circle.fill", text: "Connected", color: .green) - case .running: - statusBadge(icon: "clock.arrow.circlepath", text: "Starting…", color: .orange) - case .warning: - statusBadge(icon: "exclamationmark.triangle.fill", text: "Waiting", color: .yellow) - case .error: - statusBadge(icon: "xmark.circle.fill", text: "Error", color: .red) - case .idle: - statusBadge(icon: "pause.circle", text: "Idle", color: .secondary) - } - } - - private func statusBadge(icon: String, text: String, color: Color) -> some View { - Label(text, systemImage: icon) - .font(.footnote.weight(.semibold)) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background( - Capsule(style: .continuous) - .fill(color.opacity(0.15)) - ) - .foregroundStyle(color) - } - - private func iconOnlyStatusBadge(icon: String, text: String, color: Color) -> some View { - Label(text, systemImage: icon) - .font(.footnote.weight(.semibold)) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background( - Circle() - .fill(color.opacity(0.15)) - ) - .foregroundStyle(color) - .labelStyle(.iconOnly) - } - - private var connectionHasError: Bool { false } - - private var allStatusIndicatorsGreen: Bool { - heartbeatIndicatorStatus == .success - } - - private var statusLightsRow: some View { - HStack(alignment: .center) { - ForEach(statusLights) { light in - if let action = light.action { - Button(action: action) { - StatusLightView(light: light) - } - .buttonStyle(.plain) - .disabled(!light.isEnabled) - } else { - StatusLightView(light: light) - } - } - }.frame(maxWidth: .infinity) - } - - private var statusLights: [StatusLightData] { - [ - StatusLightData( - type: .heartbeat, - title: "Heartbeat", - icon: "waveform.path.ecg", - status: heartbeatIndicatorStatus, - detail: heartbeatDetailText - ) - ] - } - - private var wifiDetailText: String { "Connected" } - private var heartbeatDetailText: String { - if heartbeatOK { return "Active" } - if pairingFileExists { return "Waiting" } - return "Pair first" - } - - private var refreshIndicatorStatus: StartupIndicatorStatus { - .success - } - - private func color(for indicator: StartupIndicatorStatus) -> Color { - indicator.tint - } - - private func startWiFiMonitoring() { - guard wifiMonitor == nil else { return } - let monitor = NWPathMonitor(requiredInterfaceType: .wifi) - wifiMonitor = monitor - monitor.pathUpdateHandler = { path in - DispatchQueue.main.async { - wifiConnected = path.status == .satisfied - } + private func autoScript(for bundleID: String) -> (data: Data, name: String)? { + guard ProcessInfo.processInfo.hasTXM else { return nil } + guard #available(iOS 26, *) else { return nil } + let appName = (try? JITEnableContext.shared.getAppList()[bundleID]) ?? storedFavoriteName(for: bundleID) + guard let appName, + let resource = autoScriptResource(for: appName) else { + return nil + } + let scriptsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + .appendingPathComponent("scripts") + let documentsURL = scriptsDir.appendingPathComponent(resource.fileName) + if let data = try? Data(contentsOf: documentsURL) { + return (data, resource.fileName) } - monitor.start(queue: DispatchQueue.global(qos: .utility)) - } - - private func stopWiFiMonitoring() { - wifiMonitor?.cancel() - wifiMonitor = nil - } - - private func startCellularMonitoring() { - guard cellularMonitor == nil else { return } - let monitor = NWPathMonitor(requiredInterfaceType: .cellular) - cellularMonitor = monitor - monitor.pathUpdateHandler = { path in - DispatchQueue.main.async { - isCellularActive = path.status == .satisfied - } + guard let bundleURL = Bundle.main.url(forResource: resource.resource, withExtension: "js"), + let data = try? Data(contentsOf: bundleURL) else { + return nil } - monitor.start(queue: DispatchQueue.global(qos: .utility)) + return (data, resource.fileName) } - private func stopCellularMonitoring() { - cellularMonitor?.cancel() - cellularMonitor = nil - isCellularActive = false + private func assignedScript(for bundleID: String) -> (data: Data, name: String)? { + guard let mapping = UserDefaults.standard.dictionary(forKey: "BundleScriptMap") as? [String: String], + let scriptName = mapping[bundleID] else { return nil } + let scriptsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + .appendingPathComponent("scripts") + let scriptURL = scriptsDir.appendingPathComponent(scriptName) + guard FileManager.default.fileExists(atPath: scriptURL.path), + let data = try? Data(contentsOf: scriptURL) else { return nil } + return (data, scriptName) } - private var pairingStatusDescription: String { - if isValidatingPairingFile { return "Validating pairing file…" } - if pairingFileExists { - return "Pairing file imported and ready." + private func preferredScript(for bundleID: String) -> (data: Data, name: String)? { + if let assigned = assignedScript(for: bundleID) { + return assigned } - if pairingFilePresentOnDisk { - return "We found a pairing file on disk but couldn’t read it. Import a new one." - } - return "Import the pairing file generated from your trusted computer." + return autoScript(for: bundleID) } - private var wifiStatusDescription: String { - "Wi-Fi connected and ready." + private func storedFavoriteName(for bundleID: String) -> String? { + let defaults = UserDefaults(suiteName: "group.com.stik.sj") + let names = defaults?.dictionary(forKey: "favoriteAppNames") as? [String: String] + return names?[bundleID] } - private var isConnectionCheckRunning: Bool { false } - - private var wifiIndicatorStatus: StartupIndicatorStatus { .success } - - private var heartbeatSubtitle: String { - if heartbeatOK { - return "Heartbeat is responding." - } - if !requiresLoopbackVPN && pairingFileExists { - return "Waiting for a response." - } - if pairingFileLikelyInvalid { - return "Heartbeat is blocked because the pairing file looks invalid." - } - if !pairingFileExists { - return "Import a pairing file to start the heartbeat." - } - if case .running = connectionCheckState { - return "Waiting for the connection check to finish." - } - if case .success = connectionCheckState { - return "We’ll start heartbeat automatically—leave the app open." + private func autoScriptResource(for appName: String) -> (resource: String, fileName: String)? { + switch appName { + case "maciOS": + return ("maciOS", "maciOS.js") + case "Amethyst", "MeloNX": + return ("Amethyst-MeloNX", "Amethyst-MeloNX.js") + case "Geode": + return ("Geode", "Geode.js") + case "Manic EMU": + return ("manic", "manic.js") + case "UTM", "DolphiniOS", "Flycast": + return ("UTM-Dolphin", "UTM-Dolphin.js") + default: + return nil } - return "Heartbeat runs after the connection check completes." } - private var heartbeatIndicatorStatus: StartupIndicatorStatus { - if heartbeatOK { return .success } - return .warning - } - - private var connectionCheckButtonLabel: some View { - compactControlButton( - icon: "waveform.path.ecg", - title: isConnectionCheckRunning ? "Checking…" : "Run Check", - showSpinner: isConnectionCheckRunning - ) - } - - private var primaryActionControls: some View { - VStack(spacing: 8) { - Button(action: primaryActionTapped) { - whiteCardButtonLabel( - icon: primaryActionIcon, - title: primaryActionTitle, - isLoading: isProcessing || isValidatingPairingFile - ) - } - .disabled(isProcessing || isValidatingPairingFile) + private func getJsCallback(_ script: Data, name: String? = nil) -> DebugAppCallback { + return { pid, debugProxyHandle, remoteServerHandle, semaphore in + let model = RunJSViewModel(pid: Int(pid), + debugProxy: debugProxyHandle, + remoteServer: remoteServerHandle, + semaphore: semaphore) - if pairingFileExists && enableAdvancedOptions && !pairingFileLikelyInvalid && primaryActionTitle == "Connect by App" { - Button(action: { showPIDSheet = true }) { - secondaryButtonLabel(icon: "number.circle", title: "Connect by PID") - } - .disabled(isProcessing) - } - } - } - - private func refreshStatusTapped() { - runConnectionDiagnostics() - if pairingFileExists { - startHeartbeatInBackground() - } - } - - private func runConnectionDiagnostics(autoStart: Bool = false) { - connectionTimeoutTask?.cancel() - connectionTimeoutTask = nil - connectionCheckState = .success - connectionInfoMessage = nil - if pairingFileExists && !heartbeatOK { - startHeartbeatInBackground() - } - } - - private var primaryActionTitle: String { - if isValidatingPairingFile { return "Validating…" } - if !pairingFileExists { return pairingFilePresentOnDisk ? "Import New Pairing File" : "Import Pairing File" } - if shouldPromptForWiFi { return "Connect to Wi-Fi" } - if !ddiMounted { return "Mount Developer Disk Image" } - return "Connect by App" - } - - private var primaryActionIcon: String { - if isValidatingPairingFile { return "hourglass" } - if !pairingFileExists { return pairingFilePresentOnDisk ? "arrow.clockwise" : "doc.badge.plus" } - if shouldPromptForWiFi { return "wifi.slash" } - if !ddiMounted { return "externaldrive" } - return "cable.connector.horizontal" - } - - 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) + DispatchQueue.main.async { + jsModel = model + scriptViewShow = true } - 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) - } + DispatchQueue.global(qos: .background).async { + do { try model.runScript(data: script, name: name) } + catch { showAlert(title: "Error Occurred While Executing Script.".localized, message: error.localizedDescription, showOk: true) } } - .frame(height: 8) - } - .accessibilityElement(children: .combine) - } - - 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) } - .padding(.top, 4) - .transition(.opacity) } - 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) - .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) - ) - .animation(.easeInOut(duration: 0.2), value: isLoading) + private func checkPairingFileExists() { + pairingFileExists = FileManager.default.fileExists(atPath: URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path) } - 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)) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color(UIColor.secondarySystemBackground).opacity(0.6)) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color.white.opacity(0.12), lineWidth: 1) - ) - .foregroundStyle(.primary) - } + private func startJITInBackground(bundleID: String? = nil, pid: Int? = nil, scriptData: Data? = nil, scriptName: String? = nil, triggeredByURLScheme: Bool = false) { + isProcessing = true + LogManager.shared.addInfoLog("Starting Debug for \(bundleID ?? String(pid ?? 0))") - private func pillIconButton(icon: String, title: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - HStack(spacing: 6) { - Image(systemName: icon) - .font(.system(size: 14, weight: .semibold, design: .rounded)) - Text(title) - .font(.system(.footnote, design: .rounded).weight(.semibold)) - .lineLimit(1) - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .frame(minWidth: 92, alignment: .center) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color(UIColor.secondarySystemBackground)) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(Color.white.opacity(0.12), lineWidth: 1) - ) - ) - } - .buttonStyle(.plain) - } - - private var quickConnectCard: some View { - homeCard { - VStack(alignment: .leading, spacing: 14) { - HStack(spacing: 8) { - Text("Quick Connect") - .font(.headline.weight(.semibold)) - .foregroundStyle(.primary) - - } - - Text("Favorites and recents stay within reach so you can enable debug with ease.") - .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() - let scriptInfo = preferredScript(for: item.bundleID) - startJITInBackground(bundleID: item.bundleID, - pid: nil, - scriptData: scriptInfo?.data, - scriptName: scriptInfo?.name, - triggeredByURLScheme: false) - } - ) - } - } - } - - if !canConnectByApp { - Text("Finish the pairing and mounting steps above to enable quick launches.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - - private var launchShortcutsCard: some View { - homeCard { - VStack(alignment: .leading, spacing: 14) { - Text("Launch Shortcuts".localized) - .font(.headline.weight(.semibold)) - .foregroundStyle(.primary) - - Text("Pin any app from Installed Apps and launch it here with ease.".localized) - .font(.footnote) - .foregroundStyle(.secondary) - - VStack(spacing: 10) { - ForEach(pinnedLaunchItems) { item in - SystemPinnedRow( - item: item, - accentColor: accentColor, - isLaunching: launchingSystemApps.contains(item.bundleID), - action: { launchSystemApp(item: item) }, - onRemove: { removePinnedSystemApp(bundleID: item.bundleID) } - ) - } - } - } - } - } - - private var quickConnectItems: [QuickConnectItem] { - var seen = Set() - 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 var pinnedLaunchItems: [SystemPinnedItem] { - pinnedSystemApps.compactMap { bundleID in - let raw = pinnedSystemAppNames[bundleID] ?? friendlyName(for: bundleID) - let displayName = shortDisplayName(from: raw) - return SystemPinnedItem(bundleID: bundleID, displayName: displayName) - } - } - - // Prefer CoreDevice-reported app name, trimmed to a Home Screen–style label; else fall back to bundle ID last component. - private func friendlyName(for bundleID: String) -> String { - if let cached = cachedAppNames[bundleID], !cached.isEmpty { - return shortDisplayName(from: 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 + if triggeredByURLScheme { + pubHeartBeat = false + startHeartbeatInBackground(showErrorUI: false) } - - // Heuristic “Home Screen” shortener for long marketing names. - private func shortDisplayName(from name: String) -> String { - var s = name - - // Keep only the part before common separators/subtitles. - let separators = [" — ", " – ", " - ", ":", "|", "·", "•"] - for sep in separators { - if let r = s.range(of: sep) { - s = String(s[..(@ViewBuilder content: () -> Content) -> some View { - content() - .padding(20) - .background(UIKitCardBackground()) - } - // UIKit blur/shadow chrome keeps heavy effects out of SwiftUI while leaving layout in SwiftUI so the content still renders. - private struct UIKitCardBackground: UIViewRepresentable { - func makeUIView(context: Context) -> CardChromeView { - CardChromeView() + var scriptData = scriptData + var scriptName = scriptName + if scriptData == nil, + let bundleID, + let preferred = preferredScript(for: bundleID) { + scriptName = preferred.name + scriptData = preferred.data } - func updateUIView(_ uiView: CardChromeView, context: Context) { - uiView.setNeedsLayout() + var callback: DebugAppCallback? = nil + if ProcessInfo.processInfo.hasTXM, let sd = scriptData { + callback = getJsCallback(sd, name: scriptName ?? bundleID ?? "Script") } - } - - private final class CardChromeView: UIView { - private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial)) - private let strokeLayer = CAShapeLayer() - - override init(frame: CGRect) { - super.init(frame: frame) - backgroundColor = .clear - isUserInteractionEnabled = false - - layer.cornerRadius = 20 - layer.cornerCurve = .continuous - layer.masksToBounds = false - layer.shadowColor = UIColor.black.withAlphaComponent(0.15).cgColor - layer.shadowOpacity = 1 - layer.shadowRadius = 12 - layer.shadowOffset = CGSize(width: 0, height: 4) - - blurView.translatesAutoresizingMaskIntoConstraints = false - blurView.clipsToBounds = true - blurView.layer.cornerRadius = 20 - blurView.layer.cornerCurve = .continuous - addSubview(blurView) - - NSLayoutConstraint.activate([ - blurView.leadingAnchor.constraint(equalTo: leadingAnchor), - blurView.trailingAnchor.constraint(equalTo: trailingAnchor), - blurView.topAnchor.constraint(equalTo: topAnchor), - blurView.bottomAnchor.constraint(equalTo: bottomAnchor) - ]) - strokeLayer.strokeColor = UIColor.white.withAlphaComponent(0.15).cgColor - strokeLayer.fillColor = UIColor.clear.cgColor - layer.addSublayer(strokeLayer) - } - - required init?(coder: NSCoder) { - nil - } - - override func layoutSubviews() { - super.layoutSubviews() - strokeLayer.frame = bounds - strokeLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 20).cgPath - layer.shadowPath = strokeLayer.path - } - } - - private func compactControlButton(icon: String, title: String, showSpinner: Bool = false) -> some View { - HStack(spacing: 6) { - if showSpinner { - ProgressView() - .progressViewStyle(.circular) - .controlSize(.small) - } else { - Image(systemName: icon) - .font(.system(size: 14, weight: .semibold, design: .rounded)) - } - Text(title) - .font(.caption.weight(.semibold)) - } - .padding(.vertical, 8) - .padding(.horizontal, 12) - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color(UIColor.secondarySystemBackground).opacity(0.8)) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color.white.opacity(0.1), lineWidth: 1) - ) - } - - 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: "Ensure your pairing is imported and valid, connect to Wi-Fi and force-restart StikDebug.") - } - 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) - .foregroundStyle(accentColor) - .font(.system(size: 18, weight: .semibold)) - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.subheadline.weight(.semibold)) - Text(message) - .font(.footnote) - .foregroundStyle(.secondary) - } - Spacer(minLength: 0) - } - .padding(.vertical, 4) - } - - private func primaryActionTapped() { - guard !isValidatingPairingFile else { return } - if pairingFileLikelyInvalid { - if shouldPromptForWiFi { - showAlert( - title: "Wi-Fi Required", - message: "Connect to Wi-Fi.", - showOk: true - ) { _ in } - } else { - isShowingPairingFilePicker = true - } - return - } - if !ddiMounted { - showAlert(title: "Device Not Mounted".localized, message: "The Developer Disk Image has not been mounted yet. Check in settings for more information.".localized, showOk: true) { _ in } - return - } - isShowingInstalledApps = true - } - - private func showCopiedToast() { - withAnimation { justCopied = true } - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - withAnimation { justCopied = false } - } - } - - @ViewBuilder private func toast(_ text: String) -> some View { - VStack { - Spacer() - Text(text) - .font(.footnote.weight(.semibold)) - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(.ultraThinMaterial, in: Capsule()) - .overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 1)) - .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 3) - .transition(.move(edge: .bottom).combined(with: .opacity)) - .padding(.bottom, 30) - } - .animation(.easeInOut(duration: 0.25), value: text) - } - - private func checkPairingFileExists() { - // Home screen no longer blocks on pairing file checks; assume available. - pairingFileExists = true - pairingFilePresentOnDisk = true - isValidatingPairingFile = false - } - - private func needsValidation(for signature: PairingFileSignature) -> Bool { - guard let lastSignature = lastValidatedPairingSignature else { return true } - return lastSignature != signature - } - - - private func pairingFileSignature(for url: URL) -> PairingFileSignature { - let attributes = (try? FileManager.default.attributesOfItem(atPath: url.path)) ?? [:] - let modificationDate = attributes[.modificationDate] as? Date - let sizeValue = (attributes[.size] as? NSNumber)?.uint64Value ?? 0 - return PairingFileSignature(modificationDate: modificationDate, fileSize: sizeValue) - } - private func refreshBackground() { } - - private func autoScript(for bundleID: String) -> (data: Data, name: String)? { - guard ProcessInfo.processInfo.hasTXM else { return nil } - guard #available(iOS 26, *) else { return nil } - let appName = (try? JITEnableContext.shared.getAppList()[bundleID]) ?? storedFavoriteName(for: bundleID) - guard let appName, - let resource = autoScriptResource(for: appName) else { - return nil - } - let scriptsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - .appendingPathComponent("scripts") - let documentsURL = scriptsDir.appendingPathComponent(resource.fileName) - if let data = try? Data(contentsOf: documentsURL) { - return (data, resource.fileName) - } - guard let bundleURL = Bundle.main.url(forResource: resource.resource, withExtension: "js"), - let data = try? Data(contentsOf: bundleURL) else { - return nil - } - return (data, resource.fileName) - } - - private func assignedScript(for bundleID: String) -> (data: Data, name: String)? { - guard let mapping = UserDefaults.standard.dictionary(forKey: "BundleScriptMap") as? [String: String], - let scriptName = mapping[bundleID] else { return nil } - let scriptsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - .appendingPathComponent("scripts") - let scriptURL = scriptsDir.appendingPathComponent(scriptName) - guard FileManager.default.fileExists(atPath: scriptURL.path), - let data = try? Data(contentsOf: scriptURL) else { return nil } - return (data, scriptName) - } - - private func preferredScript(for bundleID: String) -> (data: Data, name: String)? { - if let assigned = assignedScript(for: bundleID) { - return assigned - } - return autoScript(for: bundleID) - } - - private func storedFavoriteName(for bundleID: String) -> String? { - let defaults = UserDefaults(suiteName: "group.com.stik.sj") - let names = defaults?.dictionary(forKey: "favoriteAppNames") as? [String: String] - return names?[bundleID] - } - - private func syncFavoriteAppNamesWithCache() { - guard let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj") else { return } - let favorites = sharedDefaults.stringArray(forKey: "favoriteApps") ?? [] - guard !favorites.isEmpty else { return } - - var storedNames = (sharedDefaults.dictionary(forKey: "favoriteAppNames") as? [String: String]) ?? [:] - var changed = false - - for bundle in favorites { - guard let rawName = cachedAppNames[bundle], !rawName.isEmpty else { continue } - let display = shortDisplayName(from: rawName) - if storedNames[bundle] != display { - storedNames[bundle] = display - changed = true - } - } - - if changed { - sharedDefaults.set(storedNames, forKey: "favoriteAppNames") - WidgetCenter.shared.reloadTimelines(ofKind: "FavoritesWidget") - } - } - - private func autoScriptResource(for appName: String) -> (resource: String, fileName: String)? { - switch appName { - case "maciOS": - return ("maciOS", "maciOS.js") - case "Amethyst", "MeloNX": - return ("Amethyst-MeloNX", "Amethyst-MeloNX.js") - case "Geode": - return ("Geode", "Geode.js") - case "Manic EMU": - return ("manic", "manic.js") - case "UTM", "DolphiniOS", "Flycast": - return ("UTM-Dolphin", "UTM-Dolphin.js") - default: - return nil - } - } - - private func getJsCallback(_ script: Data, name: String? = nil) -> DebugAppCallback { - return { pid, debugProxyHandle, remoteServerHandle, semaphore in - let model = RunJSViewModel(pid: Int(pid), - debugProxy: debugProxyHandle, - remoteServer: remoteServerHandle, - semaphore: semaphore) - + let logger: LogFunc = { message in if let message { LogManager.shared.addInfoLog(message) } } + var success: Bool + if let pid { + success = JITEnableContext.shared.debugApp(withPID: Int32(pid), logger: logger, jsCallback: callback) + } else if let bundleID { + success = JITEnableContext.shared.debugApp(withBundleID: bundleID, logger: logger, jsCallback: callback) + } else { DispatchQueue.main.async { - jsModel = model - scriptViewShow = true - pipRequired = true - } - - DispatchQueue.global(qos: .background).async { - do { try model.runScript(data: script, name: name) } - catch { showAlert(title: "Error Occurred While Executing Script.".localized, message: error.localizedDescription, showOk: true) } - } - } - } - - private func startJITInBackground(bundleID: String? = nil, pid : Int? = nil, scriptData: Data? = nil, scriptName: String? = nil, triggeredByURLScheme: Bool = false) { - isProcessing = true - LogManager.shared.addInfoLog("Starting Debug for \(bundleID ?? String(pid ?? 0))") - - DispatchQueue.global(qos: .background).async { - let finishProcessing = { - DispatchQueue.main.async { - isProcessing = false - pipRequired = false - } - } - - var scriptData = scriptData - var scriptName = scriptName - if scriptData == nil, - let bundleID, - let preferred = preferredScript(for: bundleID) { - scriptName = preferred.name - scriptData = preferred.data - } - - var callback: DebugAppCallback? = nil - if ProcessInfo.processInfo.hasTXM, let sd = scriptData { - callback = getJsCallback(sd, name: scriptName ?? bundleID ?? "Script") - if triggeredByURLScheme { usleep(500000) } - DispatchQueue.main.async { pipRequired = true } - } else { - DispatchQueue.main.async { pipRequired = false } + showAlert(title: "Failed to Debug App".localized, message: "Either bundle ID or PID should be specified.".localized, showOk: true) } - - let logger: LogFunc = { message in if let message { LogManager.shared.addInfoLog(message) } } - var success: Bool - if let pid { - success = JITEnableContext.shared.debugApp(withPID: Int32(pid), logger: logger, jsCallback: callback) - if success { DispatchQueue.main.async { addRecentPID(pid) } } - } else if let bundleID { - success = JITEnableContext.shared.debugApp(withBundleID: bundleID, logger: logger, jsCallback: callback) - } else { - DispatchQueue.main.async { - showAlert(title: "Failed to Debug App".localized, message: "Either bundle ID or PID should be specified.".localized, showOk: true) - } - success = false - } - - if success { - DispatchQueue.main.async { - LogManager.shared.addInfoLog("Debug process completed for \(bundleID ?? String(pid ?? 0))") - } - } - finishProcessing() + success = false } - } - - private func launchSystemApp(item: SystemPinnedItem) { - guard !launchingSystemApps.contains(item.bundleID) else { return } - launchingSystemApps.insert(item.bundleID) - HapticFeedbackHelper.trigger() - - DispatchQueue.global(qos: .userInitiated).async { - let success = JITEnableContext.shared.launchAppWithoutDebug(item.bundleID, logger: nil) - + + if success { DispatchQueue.main.async { - launchingSystemApps.remove(item.bundleID) - if success { - LogManager.shared.addInfoLog("Launch request sent for \(item.bundleID)") - systemLaunchMessage = String(format: "Launch requested: %@".localized, item.displayName) - } else { - LogManager.shared.addErrorLog("Failed to launch \(item.bundleID)") - systemLaunchMessage = String(format: "Failed to launch %@".localized, item.displayName) - } - scheduleSystemToastDismiss() - } - } - } - - private func removePinnedSystemApp(bundleID: String) { - Haptics.light() - pinnedSystemApps.removeAll { $0 == bundleID } - pinnedSystemAppNames.removeValue(forKey: bundleID) - persistPinnedSystemApps() - } - - private func scheduleSystemToastDismiss() { - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - if systemLaunchMessage != nil { - withAnimation { - systemLaunchMessage = nil - } - } - } - } - - private func persistPinnedSystemApps() { - if let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj") { - sharedDefaults.set(pinnedSystemApps, forKey: "pinnedSystemApps") - sharedDefaults.set(pinnedSystemAppNames, forKey: "pinnedSystemAppNames") - } - WidgetCenter.shared.reloadAllTimelines() - } - - private func addRecentPID(_ pid: Int) { - var list = recentPIDs.filter { $0 != pid } - list.insert(pid, at: 0) - if list.count > 8 { list = Array(list.prefix(8)) } - recentPIDs = list - } - - func base64URLToBase64(_ base64url: String) -> String { - var base64 = base64url.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") - let pad = 4 - (base64.count % 4) - if pad < 4 { base64 += String(repeating: "=", count: pad) } - return base64 - } - - private struct StatusDot: View { - var color: Color - @Environment(\.colorScheme) private var colorScheme - var body: some View { - ZStack { - Circle().fill(color.opacity(0.25)).frame(width: 20, height: 20) - Circle().fill(color).frame(width: 12, height: 12) - .shadow(color: color.opacity(0.6), radius: 4, x: 0, y: 0) - } - .overlay( - Circle().stroke(colorScheme == .dark ? Color.white.opacity(0.15) : Color.black.opacity(0.1), lineWidth: 0.5) - ) - } - } - - 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 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) - .minimumScaleFactor(0.8) - - 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 SystemPinnedRow: View { - let item: SystemPinnedItem - let accentColor: Color - let isLaunching: Bool - var action: () -> Void - var onRemove: () -> 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) - .minimumScaleFactor(0.8) - - Text(item.bundleID) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .textSelection(.enabled) - } - - Spacer(minLength: 0) - - if isLaunching { - ProgressView() - .controlSize(.small) - .tint(accentColor) - } else { - Image(systemName: "play.fill") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(accentColor) - } - } - .padding(.vertical, 12) - .padding(.horizontal, 14) - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(Color(UIColor.secondarySystemBackground).opacity(0.6)) - ) - .overlay( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(Color.white.opacity(0.1), lineWidth: 1) - ) - } - .buttonStyle(.plain) - .disabled(isLaunching) - .contextMenu { - Button("Remove from Home".localized, systemImage: "star.slash") { - onRemove() - } - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { - onRemove() - } label: { - Label("Remove".localized, systemImage: "trash") - } - } - } - } - - 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 StatusLightView: View { - let light: StatusLightData - - var body: some View { - let tint = light.tintOverride ?? light.status.tint - VStack(spacing: 6) { - ZStack { - Circle() - .fill(tint.opacity(0.18)) - .frame(width: 48, height: 48) - Image(systemName: light.icon) - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(tint) - } - .overlay( - Circle() - .stroke(Color.white.opacity(0.12), lineWidth: 0.5) - .frame(width: 48, height: 48) - ) - .overlay(alignment: .bottomTrailing) { - Image(systemName: light.indicatorIconName ?? light.status.iconName) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(light.indicatorColor ?? light.status.symbolColor) - .padding(4) - .background( - Circle() - .fill(Color(.systemBackground)) - .shadow(color: .black.opacity(0.12), radius: 1.5, x: 0, y: 1) - ) - .offset(x: 6, y: 6) - } - - VStack(spacing: 0) { - Text(light.title) - .font(.caption2.weight(.semibold)) - Text(light.detail) - .font(.caption2) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .frame(width: 80) - } - .accessibilityElement(children: .ignore) - .accessibilityLabel("\(light.title) status") - .accessibilityValue("\(light.detail). \(light.status.accessibilityDescription)") - } - } - - private struct StatusLightData: Identifiable { - let id = UUID() - let type: StatusLightType - let title: String - let icon: String - let status: StartupIndicatorStatus - let detail: String - let action: (() -> Void)? - let isEnabled: Bool - let indicatorIconName: String? - let indicatorColor: Color? - let tintOverride: Color? - - init(type: StatusLightType, - title: String, - icon: String, - status: StartupIndicatorStatus, - detail: String, - action: (() -> Void)? = nil, - isEnabled: Bool = true, - indicatorIconName: String? = nil, - indicatorColor: Color? = nil, - tintOverride: Color? = nil) { - self.type = type - self.title = title - self.icon = icon - self.status = status - self.detail = detail - self.action = action - self.isEnabled = isEnabled - self.indicatorIconName = indicatorIconName - self.indicatorColor = indicatorColor - self.tintOverride = tintOverride - } - } - - private enum StatusLightType { - case ddi - case wifi - case heartbeat - case refresh - } - - private struct PairingFileSignature: Equatable { - let modificationDate: Date? - let fileSize: UInt64 - } - - private enum ConnectionCheckState: Equatable { - case idle - case running - case success - case failure(String) - case timeout - } - - private enum StartupIndicatorStatus: Equatable { - case idle - case running - case success - case warning - case error - - var iconName: String { - switch self { - case .success: return "checkmark.circle.fill" - case .warning: return "exclamationmark.triangle.fill" - case .error: return "xmark.circle.fill" - case .idle: return "circle" - case .running: return "clock.arrow.circlepath" - } - } - - var tint: Color { - switch self { - case .success: return .green - case .warning: return .yellow - case .error: return .red - case .idle: return .secondary - case .running: return .orange - } - } - - var symbolColor: Color { - switch self { - case .success: return .green - case .warning: return .orange - case .error: return .red - case .idle: return .secondary - case .running: return .blue - } - } - - var accessibilityDescription: String { - switch self { - case .success: return "Success" - case .warning: return "Warning" - case .error: return "Error" - case .idle: return "Idle" - case .running: return "In progress" - } - } - } - private struct QuickConnectItem: Identifiable { - let bundleID: String - let displayName: String - var id: String { bundleID } - } - - private struct SystemPinnedItem: Identifiable { - let bundleID: String - let displayName: String - var id: String { bundleID } - } - - // MARK: - Connect-by-PID Sheet (minus/plus removed) - - private struct ConnectByPIDSheet: View { - @Environment(\.dismiss) private var dismiss - @Binding var recentPIDs: [Int] - @State private var pidText: String = "" - @State private var errorText: String? = nil - @FocusState private var focused: Bool - var onPasteCopyToast: () -> Void - var onConnect: (Int) -> Void - - private var isValid: Bool { - if let v = Int(pidText), v > 0 { return true } - return false - } - - private let capsuleHeight: CGFloat = 40 - - var body: some View { - NavigationView { - ZStack { - Color.clear.ignoresSafeArea() - - ScrollView { - VStack(spacing: 20) { - VStack(alignment: .leading, spacing: 14) { - Text("Enter a Process ID").font(.headline).foregroundColor(.primary) - - TextField("e.g. 1234", text: $pidText) - .keyboardType(.numberPad) - .textContentType(.oneTimeCode) - .font(.system(.title3, design: .rounded)) - .padding(12) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(.ultraThinMaterial) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(Color.white.opacity(0.12), lineWidth: 1) - ) - .focused($focused) - .onChange(of: pidText) { _, newVal in validate(newVal) } - - // Paste + Clear row - HStack(spacing: 10) { - CapsuleButton(systemName: "doc.on.clipboard", title: "Paste", height: capsuleHeight) { - if let n = UIPasteboard.general.string?.trimmingCharacters(in: .whitespacesAndNewlines), - let v = Int(n), v > 0 { - pidText = String(v) - validate(pidText) - onPasteCopyToast() - } else { - errorText = "No valid PID on the clipboard." - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } - } - - CapsuleButton(systemName: "xmark", title: "Clear", height: capsuleHeight) { - pidText = "" - errorText = nil - } - } - - - if let errorText { - HStack(spacing: 6) { - Image(systemName: "exclamationmark.triangle.fill").font(.footnote) - Text(errorText).font(.footnote) - } - .foregroundColor(.orange) - .transition(.opacity) - } - - if !recentPIDs.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("Recents") - .font(.subheadline.weight(.semibold)) - .foregroundColor(.secondary) - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(recentPIDs, id: \.self) { pid in - Button { - pidText = String(pid); validate(pidText) - } label: { - Text("#\(pid)") - .font(.footnote.weight(.semibold)) - .padding(.vertical, 6) - .padding(.horizontal, 10) - .background( - Capsule(style: .continuous) - .fill(Color(UIColor.tertiarySystemBackground)) - ) - } - .contextMenu { - Button(role: .destructive) { - removeRecent(pid) - } label: { Label("Remove", systemImage: "trash") } - } - } - } - } - } - } - - Button { - guard let pid = Int(pidText), pid > 0 else { return } - onConnect(pid) - addRecent(pid) - dismiss() - } label: { - HStack { - Image(systemName: "bolt.horizontal.circle").font(.system(size: 20)) - Text("Connect") - .font(.system(.title3, design: .rounded)) - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .foregroundColor(Color.accentColor.contrastText()) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color.white.opacity(0.2), lineWidth: 0.5) - ) - } - .disabled(!isValid) - .padding(.top, 8) - } - .padding(20) - .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) - ) - ) - .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) - } - .padding(.horizontal, 20) - .padding(.vertical, 30) - } - } - .navigationTitle("Connect by PID") - .navigationBarTitleDisplayMode(.inline) - .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Cancel") { dismiss() } } } - .onAppear { focused = true } - } - } - - // Small glassy square icon button - private func iconSquareButton(systemName: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - Image(systemName: systemName) - .font(.headline) - .frame(width: 36, height: 36) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color(UIColor.tertiarySystemBackground)) - ) - } - .buttonStyle(.plain) - .contentShape(Rectangle()) - } - - private func validate(_ text: String) { - if text.isEmpty { errorText = nil; return } - if Int(text) == nil || Int(text)! <= 0 { errorText = "Please enter a positive number." } - else { errorText = nil } - } - private func addRecent(_ pid: Int) { - var list = recentPIDs.filter { $0 != pid } - list.insert(pid, at: 0) - if list.count > 8 { list = Array(list.prefix(8)) } - recentPIDs = list - } - private func removeRecent(_ pid: Int) { recentPIDs.removeAll { $0 == pid } } - private func prefillFromClipboardIfPossible() { - if let s = UIPasteboard.general.string?.trimmingCharacters(in: .whitespacesAndNewlines), - let v = Int(s), v > 0 { - pidText = String(v); errorText = nil - } - } - - @ViewBuilder private func CapsuleButton(systemName: String, title: String, height: CGFloat = 40, action: @escaping () -> Void) -> some View { - Button(action: action) { - HStack(spacing: 6) { - Image(systemName: systemName) - Text(title).font(.subheadline.weight(.semibold)) + LogManager.shared.addInfoLog("Debug process completed for \(bundleID ?? String(pid ?? 0))") + + if doAutoQuitAfterEnablingJIT { + exit(0) } - .frame(height: height) // enforce uniform height - .padding(.horizontal, 12) - .background(Capsule(style: .continuous).fill(Color(UIColor.tertiarySystemBackground))) } - .buttonStyle(.plain) - .contentShape(Rectangle()) } + finishProcessing() } + } + private func base64URLToBase64(_ base64url: String) -> String { + var base64 = base64url.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + let pad = 4 - (base64.count % 4) + if pad < 4 { base64 += String(repeating: "=", count: pad) } + return base64 } +} + +#Preview { + HomeView() +} + public extension ProcessInfo { var hasTXM: Bool { if isTXMOverridden { return true } - if DeviceLibraryStore.shared.isUsingExternalDevice { - return DeviceLibraryStore.shared.activeDevice?.isTXM ?? false - } return ProcessInfo.detectLocalTXM() } diff --git a/StikJIT/Views/InstalledAppsListView.swift b/StikJIT/Views/InstalledAppsListView.swift index 40d2cc02..77f2e740 100644 --- a/StikJIT/Views/InstalledAppsListView.swift +++ b/StikJIT/Views/InstalledAppsListView.swift @@ -15,7 +15,7 @@ import Combine struct InstalledAppsListView: View { @StateObject private var viewModel = InstalledAppsViewModel() - private let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj")! + private let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj") ?? .standard @AppStorage("recentApps") private var recentApps: [String] = [] @AppStorage("favoriteApps") private var favoriteApps: [String] = [] { @@ -41,10 +41,16 @@ struct InstalledAppsListView: View { @Environment(\.dismiss) private var dismiss var onSelectApp: (String) -> Void - @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 currentSearchBinding: Binding { + Binding( + get: { selectedTab == .debuggable ? debuggableSearchText : launchSearchText }, + set: { + if selectedTab == .debuggable { debuggableSearchText = $0 } + else { launchSearchText = $0 } + } + ) + } private var debuggableSearchIsActive: Bool { !debuggableSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -119,8 +125,8 @@ private enum AppListTab: Int, CaseIterable, Identifiable { var title: String { switch self { - case .debuggable: return "Debuggable" - case .launch: return "Launch Apps" + case .debuggable: return "JIT" + case .launch: return "Other" } } } @@ -155,48 +161,51 @@ private enum AppListTab: Int, CaseIterable, Identifiable { var body: some View { NavigationStack { - ZStack { - ThemedBackground(style: backgroundStyle) - .ignoresSafeArea() - - tabbedContent - .transition(.opacity) - .transaction { t in t.disablesAnimations = true } - - if let feedback = launchFeedback { - VStack { - Spacer() - Text(feedback.message) - .font(.subheadline.weight(.semibold)) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background( - Capsule() - .fill(.ultraThinMaterial) - .overlay( - Capsule() - .stroke(feedback.success ? Color.green.opacity(0.35) : Color.red.opacity(0.35), lineWidth: 1) - ) - ) - .foregroundStyle(feedback.success ? .green : .red) - .shadow(radius: 4) - .transition(.move(edge: .bottom).combined(with: .opacity)) - .padding(.bottom, 40) + tabContent(for: selectedTab) + .transition(.opacity) + .transaction { t in t.disablesAnimations = true } + .navigationTitle("Installed Apps".localized) + .searchable( + text: currentSearchBinding, + placement: .navigationBarDrawer(displayMode: .always), + prompt: selectedTab == .debuggable + ? "Search apps or bundle ID".localized + : "Search".localized + ) + .toolbar { + ToolbarItem(placement: .principal) { + Picker("", selection: $selectedTab) { + ForEach(AppListTab.allCases) { tab in + Text(tab.title.localized).tag(tab) + } + } + .pickerStyle(.segmented) + .frame(width: 220) + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { dismiss() }.fontWeight(.semibold) } - .animation(.spring(response: 0.3, dampingFraction: 0.8), value: launchFeedback?.id) } - } - .navigationTitle("Installed Apps".localized) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("Done") { dismiss() } - .fontWeight(.semibold) + .onAppear { + } + } + .overlay { + if let feedback = launchFeedback { + VStack { + Spacer() + Text(feedback.message) + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 16).padding(.vertical, 8) + .background(Capsule().fill(.ultraThinMaterial)) + .foregroundStyle(feedback.success ? .green : .red) + .shadow(radius: 4) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .padding(.bottom, 40) } + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: launchFeedback?.id) } - .ignoresSafeArea(edges: .bottom) } - .preferredColorScheme(preferredScheme) - .onAppear { + .onAppear { prefetchedBundleIDs.removeAll() prefetchPriorityIcons() } @@ -215,50 +224,6 @@ private enum AppListTab: Int, CaseIterable, Identifiable { .onChange(of: pinnedSystemApps) { _, _ in prefetchPriorityIcons() } } - // MARK: Empty State - - private func emptyState(for tab: AppListTab) -> some View { - VStack(spacing: 16) { - Image(systemName: "magnifyingglass") - .resizable() - .scaledToFit() - .frame(width: 60, height: 60) - .foregroundStyle(.secondary) - - switch tab { - case .debuggable: - Text("No Debuggable App Found".localized) - .font(.title2.weight(.semibold)) - .foregroundStyle(.primary) - - Text(""" - StikDebug can only connect to apps with the “get-task-allow” entitlement. - Please check if the app you want to connect to is signed with a development certificate. - """.localized) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - case .launch: - Text("No Launchable Apps".localized) - .font(.title2.weight(.semibold)) - .foregroundStyle(.primary) - - Text(""" - Once your device pairing file is imported and CoreDevice is connected, all non‑debuggable and hidden system apps will appear here. - """.localized) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - } - .padding(24) - .glassCard(cornerRadius: 24, strokeOpacity: 0.12) - .accessibilityElement(children: .combine) - .accessibilityLabel(tab == .debuggable ? "No debuggable apps available" : "No launchable apps available") - } - // MARK: Apps List private func prefetchPriorityIcons(limit: Int = 32) { @@ -289,289 +254,135 @@ private enum AppListTab: Int, CaseIterable, Identifiable { AppIconRepository.prefetch(bundleIDs: toPrefetch) } - @ViewBuilder - private var tabbedContent: some View { - VStack(spacing: 14) { - SegmentedControl( - titles: AppListTab.allCases.map { $0.title.localized }, - selection: Binding( - get: { selectedTab.rawValue }, - set: { newValue in - if let tab = AppListTab(rawValue: newValue) { - selectedTab = tab - } - } - ) - ) - .padding(.horizontal, 12) - - tabContent(for: selectedTab) - } - } - @ViewBuilder private func tabContent(for tab: AppListTab) -> some View { switch tab { case .debuggable: - ScrollView { - LazyVStack(spacing: 18, pinnedViews: []) { - sectionCard { - debuggableSearchBar + List { + if let error = viewModel.lastError { + Section { + Text(error).font(.footnote).foregroundStyle(.orange) } - - if let error = viewModel.lastError { - sectionCard { - errorBanner(error) + } + if filteredDebuggableApps.isEmpty && !viewModel.isLoading { + Section { + VStack(spacing: 8) { + Image(systemName: debuggableSearchIsActive ? "text.magnifyingglass" : "magnifyingglass") + .font(.system(size: 36)).foregroundStyle(.secondary) + Text(debuggableSearchIsActive ? "No matching apps".localized : "No JIT Apps Found".localized) + .font(.headline) + Text(debuggableSearchIsActive + ? "Try a different name or bundle identifier.".localized + : "StikDebug can only connect to apps with the \"get-task-allow\" entitlement.".localized) + .font(.footnote).foregroundStyle(.secondary).multilineTextAlignment(.center) } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .listRowBackground(Color.clear) } - - if filteredDebuggableApps.isEmpty { - sectionCard { - debuggableSearchEmptyState - } - } else { - if !filteredFavoriteBundles.isEmpty { - sectionCard(title: String(format: "Favorites (%d/4)".localized, filteredFavoriteBundles.count)) { - LazyVStack(spacing: 12) { - ForEach(filteredFavoriteBundles, id: \.self) { bundleID in - AppButton( - bundleID: bundleID, - appName: viewModel.debuggableApps[bundleID] ?? fallbackReadableName(from: bundleID), - recentApps: $recentApps, - favoriteApps: $favoriteApps, - onSelectApp: onSelectApp, - sharedDefaults: sharedDefaults, - performanceMode: performanceMode - ) - } - } + } else { + if !filteredFavoriteBundles.isEmpty { + Section(String(format: "Favorites (%d/4)".localized, filteredFavoriteBundles.count)) { + ForEach(filteredFavoriteBundles, id: \.self) { bundleID in + AppButton( + bundleID: bundleID, + appName: viewModel.debuggableApps[bundleID] ?? fallbackReadableName(from: bundleID), + recentApps: $recentApps, favoriteApps: $favoriteApps, + onSelectApp: onSelectApp, sharedDefaults: sharedDefaults, performanceMode: performanceMode + ) } } - - if !filteredRecentBundles.isEmpty { - sectionCard(title: "Recents".localized) { - LazyVStack(spacing: 12) { - ForEach(filteredRecentBundles, id: \.self) { bundleID in - AppButton( - bundleID: bundleID, - appName: viewModel.debuggableApps[bundleID] ?? fallbackReadableName(from: bundleID), - recentApps: $recentApps, - favoriteApps: $favoriteApps, - onSelectApp: onSelectApp, - sharedDefaults: sharedDefaults, - performanceMode: performanceMode - ) - } - } + } + if !filteredRecentBundles.isEmpty { + Section("Recents".localized) { + ForEach(filteredRecentBundles, id: \.self) { bundleID in + AppButton( + bundleID: bundleID, + appName: viewModel.debuggableApps[bundleID] ?? fallbackReadableName(from: bundleID), + recentApps: $recentApps, favoriteApps: $favoriteApps, + onSelectApp: onSelectApp, sharedDefaults: sharedDefaults, performanceMode: performanceMode + ) } } - - sectionCard(title: "All Applications".localized) { - LazyVStack(spacing: 12) { - ForEach(filteredDebuggableApps, id: \.key) { bundleID, appName in - AppButton( - bundleID: bundleID, - appName: appName, - recentApps: $recentApps, - favoriteApps: $favoriteApps, - onSelectApp: onSelectApp, - sharedDefaults: sharedDefaults, - performanceMode: performanceMode - ) - } - } + } + Section("All Applications".localized) { + ForEach(filteredDebuggableApps, id: \.key) { bundleID, appName in + AppButton( + bundleID: bundleID, appName: appName, + recentApps: $recentApps, favoriteApps: $favoriteApps, + onSelectApp: onSelectApp, sharedDefaults: sharedDefaults, performanceMode: performanceMode + ) } } } - .padding(.horizontal, 20) - .padding(.vertical, 24) } + .listStyle(.insetGrouped) + case .launch: - ScrollView { - LazyVStack(spacing: 18, pinnedViews: []) { - sectionCard { - launchSearchBar + List { + if let error = viewModel.lastError { + Section { + Text(error).font(.footnote).foregroundStyle(.orange) } - - if let error = viewModel.lastError { - sectionCard { - errorBanner(error) + } + if filteredLaunchApps.isEmpty { + Section { + VStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 36)).foregroundStyle(.secondary) + Text(launchSearchIsActive ? "No matches".localized : "No Apps Found".localized) + .font(.headline) + Text(launchSearchIsActive + ? "Try another name or bundle identifier.".localized + : "Once your device pairing file is imported and CoreDevice is connected, all apps will appear here.".localized) + .font(.footnote).foregroundStyle(.secondary).multilineTextAlignment(.center) } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .listRowBackground(Color.clear) } - - if filteredLaunchApps.isEmpty { - sectionCard { - Group { - if launchSearchIsActive { - launchSearchEmptyState - } else { - emptyState(for: .launch) + } else { + Section("Other Apps".localized) { + ForEach(filteredLaunchApps, id: \.key) { bundleID, appName in + let isPinned = pinnedSystemApps.contains(bundleID) + LaunchAppRow( + bundleID: bundleID, appName: appName, + isLaunching: launchingBundles.contains(bundleID), + performanceMode: performanceMode + ) { startLaunching(bundleID: bundleID) } + .overlay(alignment: .topTrailing) { + if isPinned { + Image(systemName: "star.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.yellow).padding(6) } } - } - } else { - sectionCard(title: "Launchable Apps".localized) { - LazyVStack(spacing: 12) { - ForEach(filteredLaunchApps, id: \.key) { bundleID, appName in - let isPinned = pinnedSystemApps.contains(bundleID) - LaunchAppRow( - bundleID: bundleID, - appName: appName, - isLaunching: launchingBundles.contains(bundleID), - performanceMode: performanceMode - ) { - startLaunching(bundleID: bundleID) - } - .overlay(alignment: .topTrailing) { - if isPinned { - Image(systemName: "star.fill") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.yellow) - .padding(6) - } - } - .contextMenu { - Button((isPinned ? "Remove from Home" : "Add to Home").localized, systemImage: isPinned ? "star.slash" : "star") { - toggleSystemPin(bundleID: bundleID, appName: appName) - } - Button("Copy Bundle ID".localized, systemImage: "doc.on.doc") { - UIPasteboard.general.string = bundleID - Haptics.light() - } - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button { - toggleSystemPin(bundleID: bundleID, appName: appName) - } label: { - Label((isPinned ? "Unpin" : "Pin").localized, systemImage: "star") - } - .tint(.yellow) - } + .contextMenu { + Button((isPinned ? "Remove from Home" : "Add to Home").localized, + systemImage: isPinned ? "star.slash" : "star") { + toggleSystemPin(bundleID: bundleID, appName: appName) + } + Button("Copy Bundle ID".localized, systemImage: "doc.on.doc") { + UIPasteboard.general.string = bundleID + Haptics.light() } } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button { + toggleSystemPin(bundleID: bundleID, appName: appName) + } label: { + Label((isPinned ? "Unpin" : "Pin").localized, systemImage: "star") + } + .tint(.yellow) + } } } } - .padding(.horizontal, 20) - .padding(.vertical, 24) } + .listStyle(.insetGrouped) } } - 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(Color(UIColor.secondarySystemBackground).opacity(0.9)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.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, strokeOpacity: 0.12) - } - - private var launchSearchBar: some View { - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .foregroundStyle(.secondary) - - TextField("Search".localized, text: $launchSearchText) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - - if launchSearchIsActive { - Button { - launchSearchText = "" - } 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(Color(UIColor.secondarySystemBackground).opacity(0.9)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.white.opacity(0.08), lineWidth: 1) - ) - ) - } - - private var launchSearchEmptyState: some View { - VStack(spacing: 12) { - Image(systemName: "magnifyingglass") - .font(.system(size: 44, weight: .regular)) - .foregroundStyle(.secondary) - - Text("No matches".localized) - .font(.title3.weight(.semibold)) - - Text("Try another name or bundle identifier.".localized) - .font(.callout) - .foregroundStyle(.secondary) - } - .padding(24) - .glassCard(cornerRadius: 20, strokeOpacity: 0.12) - } - - private func errorBanner(_ message: String) -> some View { - Text(message) - .font(.footnote) - .foregroundStyle(.orange) - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color(UIColor.secondarySystemBackground).opacity(0.9)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.white.opacity(0.12), lineWidth: 1) - ) - ) - } - // MARK: Persistence gate (avoid redundant writes + reloads) private func persistIfChanged() { @@ -686,8 +497,6 @@ 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 @@ -697,8 +506,6 @@ struct AppButton: View { @State private var assignedScriptName: String? @StateObject private var iconLoader: IconLoader - private var rowBackgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle } - init( bundleID: String, appName: String, @@ -746,10 +553,7 @@ struct AppButton: View { .accessibilityHidden(true) } } - .padding(.vertical, loadAppIconsOnJIT ? 8 : 12) - .padding(.horizontal, 12) - .background(rowBackground) - .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .padding(.vertical, loadAppIconsOnJIT ? 4 : 8) } .buttonStyle(.plain) .contextMenu { @@ -841,12 +645,6 @@ struct AppButton: View { .accessibilityHidden(true) } - // MARK: Row Background - - private var rowBackground: some View { - ThemedRowBackground(performanceMode: performanceMode, style: rowBackgroundStyle, cornerRadius: 16) - } - // MARK: Actions private func selectApp() { @@ -919,16 +717,12 @@ struct LaunchAppRow: View { let isLaunching: Bool @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 @StateObject private var iconLoader: IconLoader - private var rowBackgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle } - init( bundleID: String, appName: String, @@ -968,24 +762,16 @@ struct LaunchAppRow: View { Spacer() if isLaunching { - ProgressView() - .controlSize(.small) + ProgressView().controlSize(.small) } else { Text("Launch".localized) .font(.footnote.weight(.semibold)) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background( - Capsule() - .fill(Color.accentColor.opacity(0.18)) - ) + .padding(.horizontal, 12).padding(.vertical, 6) + .background(Capsule().fill(Color.accentColor.opacity(0.18))) .foregroundStyle(Color.accentColor) } } - .padding(.vertical, loadAppIconsOnJIT ? 8 : 12) - .padding(.horizontal, 12) - .background(rowBackground) - .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .padding(.vertical, loadAppIconsOnJIT ? 4 : 8) } .buttonStyle(.plain) .disabled(isLaunching) @@ -1028,90 +814,6 @@ struct LaunchAppRow: View { .accessibilityHidden(true) } - private var rowBackground: some View { - ThemedRowBackground(performanceMode: performanceMode, style: rowBackgroundStyle, cornerRadius: 16) - } -} - -private struct ThemedRowBackground: View { - var performanceMode: Bool - var style: BackgroundStyle - var cornerRadius: CGFloat - @Environment(\.colorScheme) private var colorScheme - - 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)) - case .adaptiveGradient(let light, let dark): - let palette = colorScheme == .dark ? dark : light - shape - .fill( - LinearGradient( - gradient: Gradient(colors: normalized(palette)), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .opacity(0.32) - } - } - - 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] - } } private actor IconFetchRegistry { @@ -1349,62 +1051,6 @@ final class IconLoader: ObservableObject { } } -// MARK: - Shared UI Bits - -private struct BackgroundGradient: View { - var body: some View { - LinearGradient( - colors: [Color(UIColor.systemBackground), Color(UIColor.secondarySystemBackground)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - } -} - -private struct GlassCard: ViewModifier { - var cornerRadius: CGFloat = 20 - var strokeOpacity: Double = 0.15 - func body(content: Content) -> some View { - content - .background( - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .fill(Color(UIColor.secondarySystemBackground).opacity(0.95)) - .overlay( - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .stroke(Color.white.opacity(strokeOpacity), lineWidth: 1) - ) - ) - .shadow(color: .black.opacity(0.08), radius: 8, x: 0, y: 3) - } -} - -private extension View { - func glassCard( - cornerRadius: CGFloat = 20, - strokeOpacity: Double = 0.15 - ) -> some View { - modifier(GlassCard(cornerRadius: cornerRadius, strokeOpacity: strokeOpacity)) - } - - // Lightweight section container to keep layout predictable and fast. - func sectionCard( - title: String? = nil, - @ViewBuilder content: () -> some View - ) -> some View { - VStack(alignment: .leading, spacing: 12) { - if let title { - Text(title) - .font(.headline) - .foregroundStyle(.secondary) - .accessibilityAddTraits(.isHeader) - } - content() - } - .padding(16) - .glassCard(strokeOpacity: 0.12) - } -} - enum Haptics { static func light() { UIImpactFeedbackGenerator(style: .light).impactOccurred() } static func selection() { UISelectionFeedbackGenerator().selectionChanged() } @@ -1451,50 +1097,6 @@ struct InstalledAppsListView_Previews: PreviewProvider { } } -private struct SegmentedControl: UIViewRepresentable { - let titles: [String] - @Binding var selection: Int - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - func makeUIView(context: Context) -> UISegmentedControl { - let control = UISegmentedControl(items: titles) - control.selectedSegmentIndex = selection - control.addTarget(context.coordinator, action: #selector(Coordinator.valueChanged(_:)), for: .valueChanged) - return control - } - - func updateUIView(_ uiView: UISegmentedControl, context: Context) { - if uiView.numberOfSegments != titles.count { - uiView.removeAllSegments() - for (index, title) in titles.enumerated() { - uiView.insertSegment(withTitle: title, at: index, animated: false) - } - } - - for (index, title) in titles.enumerated() { - uiView.setTitle(title, forSegmentAt: index) - } - - if uiView.selectedSegmentIndex != selection { - uiView.selectedSegmentIndex = selection - } - } - - final class Coordinator: NSObject { - var parent: SegmentedControl - - init(_ parent: SegmentedControl) { - self.parent = parent - } - - @objc func valueChanged(_ sender: UISegmentedControl) { - parent.selection = sender.selectedSegmentIndex - } - } -} class InstalledAppsViewModel: ObservableObject { @Published var debuggableApps: [String: String] = [:] @@ -1552,7 +1154,6 @@ class InstalledAppsViewModel: ObservableObject { DispatchQueue.main.async { self.isLoading = false self.lastError = error.localizedDescription - print("Failed to load apps: \(error)") } } } diff --git a/StikJIT/Views/MainTabView.swift b/StikJIT/Views/MainTabView.swift index 1e383748..f60278ae 100644 --- a/StikJIT/Views/MainTabView.swift +++ b/StikJIT/Views/MainTabView.swift @@ -19,58 +19,27 @@ extension Notification.Name { } struct MainTabView: View { - @AppStorage("customAccentColor") private var customAccentColorHex: String = "" - @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue @AppStorage(TabConfiguration.storageKey) private var enabledTabIdentifiers: String = TabConfiguration.defaultRawValue @AppStorage("primaryTabSelection") private var selection: String = TabConfiguration.defaultIDs.first ?? "home" @State private var switchObserver: Any? @State private var detachedTab: TabDescriptor? @State private var didSetInitialHome = false - // Update checking - @State private var showForceUpdate: Bool = false - @State private var latestVersion: String? = nil - - @Environment(\.themeExpansionManager) private var themeExpansion - - private var accentColor: Color { - themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue - } - - private var preferredScheme: ColorScheme? { - themeExpansion?.preferredColorScheme(for: appThemeRaw) - } - - private var isAppStoreBuild: Bool { - #if APPSTORE - return true - #else - return false - #endif - } - private var configurableTabs: [TabDescriptor] { var tabs: [TabDescriptor] = [ TabDescriptor(id: "home", title: "Home", systemImage: "house") { AnyView(HomeView()) }, - TabDescriptor(id: "console", title: "Console", systemImage: "terminal") { AnyView(ConsoleLogsView()) }, TabDescriptor(id: "scripts", title: "Scripts", systemImage: "scroll") { AnyView(ScriptListView()) }, - TabDescriptor(id: "deviceinfo", title: "Device Info", systemImage: "iphone.and.arrow.forward") { AnyView(DeviceInfoView()) } + TabDescriptor(id: "tools", title: "Tools", systemImage: "wrench.and.screwdriver") { AnyView(ToolsView()) }, + TabDescriptor(id: "deviceinfo", title: "Device Info", systemImage: "iphone.and.arrow.forward") { AnyView(DeviceInfoView()) }, + TabDescriptor(id: "profiles", title: "App Expiry", systemImage: "calendar.badge.clock") { AnyView(ProfileView()) }, + TabDescriptor(id: "processes", title: "Processes", systemImage: "rectangle.stack.person.crop") { AnyView(ProcessInspectorView()) }, + TabDescriptor(id: "location", title: "Location", systemImage: "location") { AnyView(LocationSimulationView()) } ] - if FeatureFlags.showBetaTabs { - tabs.append(TabDescriptor(id: "profiles", title: "App Expiry", systemImage: "calendar.badge.clock") { AnyView(ProfileView()) }) - tabs.append(TabDescriptor(id: "processes", title: "Processes", systemImage: "rectangle.stack.person.crop") { AnyView(ProcessInspectorView()) }) - tabs.append(TabDescriptor(id: "devicelibrary", title: "Devices", systemImage: "list.bullet.rectangle") { AnyView(DeviceLibraryView()) }) - if FeatureFlags.isLocationSpoofingEnabled { - tabs.append(TabDescriptor(id: "location", title: "Location", systemImage: "location") { AnyView(LocationSimulationView()) }) - } - } return tabs } private var availableTabs: [TabDescriptor] { - configurableTabs.filter { descriptor in - descriptor.id != "location" || (!isAppStoreBuild && FeatureFlags.isLocationSpoofingEnabled && FeatureFlags.showBetaTabs) - } + configurableTabs } private let settingsTab = TabDescriptor(id: "settings", title: "Settings", systemImage: "gearshape.fill") { @@ -85,13 +54,21 @@ struct MainTabView: View { } private func ensureSelectionIsValid() { - let ids = selectedTabDescriptors.map { $0.id } - if ids.contains(selection) || selection == settingsTab.id { + let ids = displayTabs.map { $0.id } + if ids.contains(selection) { return } selection = ids.first ?? settingsTab.id } + private var displayTabs: [TabDescriptor] { + var tabs = ["home", "scripts", "tools"].compactMap { id in + configurableTabs.first(where: { $0.id == id }) + } + tabs.insert(settingsTab, at: min(3, tabs.count)) + return tabs + } + var body: some View { ZStack { // Allow global themed background to show @@ -99,19 +76,12 @@ struct MainTabView: View { // Main tabs TabView(selection: $selection) { - ForEach(selectedTabDescriptors) { descriptor in + ForEach(displayTabs) { descriptor in descriptor.builder() .tabItem { Label(descriptor.title, systemImage: descriptor.systemImage) } .tag(descriptor.id) } - - settingsTab.builder() - .tabItem { Label(settingsTab.title, systemImage: settingsTab.systemImage) } - .tag(settingsTab.id) } - .id((themeExpansion?.hasThemeExpansion == true) ? customAccentColorHex : "default-accent") - .tint(accentColor) - .preferredColorScheme(preferredScheme) .onAppear { enabledTabIdentifiers = TabConfiguration.serialize(TabConfiguration.sanitize(raw: enabledTabIdentifiers)) ensureSelectionIsValid() @@ -123,7 +93,6 @@ struct MainTabView: View { } didSetInitialHome = true } - checkForUpdate() switchObserver = NotificationCenter.default.addObserver(forName: .switchToTab, object: nil, queue: .main) { note in guard let id = note.object as? String else { return } if selectedTabDescriptors.contains(where: { $0.id == id }) { @@ -154,99 +123,13 @@ struct MainTabView: View { } } } - - if showForceUpdate { - ZStack { - Color.black.opacity(0.001).ignoresSafeArea() - - appGlassCard { - VStack(spacing: 20) { - Text("Update Required") - .font(.title.bold()) - .multilineTextAlignment(.center) - - Text("A new version (\(latestVersion ?? "unknown")) is available. Please update to continue using the app.") - .multilineTextAlignment(.center) - .font(.callout) - .foregroundColor(.secondary) - .padding(.horizontal) - - Button(action: { - let urlString: String - if isAppStoreBuild { - urlString = "itms-apps://itunes.apple.com/app/id6744045754" - } else { - urlString = "altstore://source?url=https://StikDebug.xyz/apps.json" - } - if let url = URL(string: urlString) { - UIApplication.shared.open(url) - } - }) { - Text("Update Now") - .font(.headline.weight(.semibold)) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(Color.accentColor) - ) - .foregroundColor(.white) - } - .padding(.top, 10) - } - } - .padding(.horizontal, 40) - } - .transition(.opacity.combined(with: .scale)) - .animation(.easeInOut, value: showForceUpdate) - } } } - // MARK: - Update Checker - private func checkForUpdate() { - guard let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { return } - - fetchLatestVersion { latest in - latestVersion = latest - if let latest = latest, - latest.compare(currentVersion, options: .numeric) == .orderedDescending { - DispatchQueue.main.async { - showForceUpdate = true - } - } - } - } - - private func fetchLatestVersion(completion: @escaping (String?) -> Void) { - guard let url = URL(string: "https://itunes.apple.com/lookup?id=6744045754") else { - completion(nil) - return - } - - URLSession.shared.dataTask(with: url) { data, _, _ in - guard let data = data else { - completion(nil) - return - } - do { - if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let results = json["results"] as? [[String: Any]], - let appStoreVersion = results.first?["version"] as? String { - completion(appStoreVersion) - } else { - completion(nil) - } - } catch { - completion(nil) - } - }.resume() - } } struct MainTabView_Previews: PreviewProvider { static var previews: some View { MainTabView() - .themeExpansionManager(ThemeExpansionManager(previewUnlocked: true)) } } diff --git a/StikJIT/Views/MapSelectionView.swift b/StikJIT/Views/MapSelectionView.swift index 70bf1599..f77d6a86 100644 --- a/StikJIT/Views/MapSelectionView.swift +++ b/StikJIT/Views/MapSelectionView.swift @@ -8,113 +8,28 @@ import SwiftUI import MapKit import UIKit -import Pipify -struct MapSelectionView: UIViewRepresentable { - @Binding var coordinate: CLLocationCoordinate2D? - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - class Coordinator: NSObject, MKMapViewDelegate { - var parent: MapSelectionView - - init(_ parent: MapSelectionView) { - self.parent = parent - } - - @objc func handleLongPress(_ gesture: UILongPressGestureRecognizer) { - guard gesture.state == .began, - let map = gesture.view as? MKMapView - else { return } - - let point = gesture.location(in: map) - parent.coordinate = map.convert(point, toCoordinateFrom: map) - } - } - - func makeUIView(context: Context) -> MKMapView { - let mapView = MKMapView(frame: .zero) - mapView.delegate = context.coordinator - - let longPress = UILongPressGestureRecognizer( - target: context.coordinator, - action: #selector(Coordinator.handleLongPress(_:)) - ) - mapView.addGestureRecognizer(longPress) - - return mapView - } - - func updateUIView(_ uiView: MKMapView, context: Context) { - uiView.removeAnnotations(uiView.annotations) - - if let coord = coordinate { - let annotation = MKPointAnnotation() - annotation.coordinate = coord - uiView.addAnnotation(annotation) - - let region = MKCoordinateRegion( - center: coord, - latitudinalMeters: 500, - longitudinalMeters: 500 - ) - uiView.setRegion(region, animated: true) - } +extension CLLocationCoordinate2D: Equatable { + public static func == (lhs: CLLocationCoordinate2D, rhs: CLLocationCoordinate2D) -> Bool { + lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude } } -final class LocationSpoofingPiPState: ObservableObject { - @Published var status: String = "Idle" - @Published var coordinate: CLLocationCoordinate2D? - @Published var lastUpdated: Date? -} - struct LocationSimulationView: View { - @Environment(\.themeExpansionManager) private var themeExpansion + // Serial queue: simulate_location and clear_simulated_location share C global + // state — serialising all calls eliminates the use-after-free race. + private static let locationQueue = DispatchQueue(label: "com.stik.location-sim", + qos: .userInitiated) + @State private var coordinate: CLLocationCoordinate2D? - @State private var statusMessage: String = "" - @State private var statusIsError = false - @State private var showKeepOpenAlert = false - @State private var searchQuery = "" - @State private var searchResults: [SearchResult] = [] + @State private var position: MapCameraPosition = .userLocation(fallback: .automatic) + @State private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid @State private var resendTimer: Timer? - @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue - @AppStorage("enablePiP") private var enablePiP = true - @State private var pipPresented = false - @StateObject private var pipState = LocationSpoofingPiPState() - - private var backgroundStyle: BackgroundStyle { - themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle - } - - private var isAppStoreBuild: Bool { - #if APPSTORE - return true - #else - return false - #endif - } + @State private var isBusy = false private var pairingFilePath: String { - let docPathUrl = FileManager.default - .urls(for: .documentDirectory, in: .userDomainMask)[0] - let currentDeviceUUIDStr = UserDefaults.standard.string(forKey: "DeviceLibraryActiveDeviceID") - - let pairingFileURL: URL - if let uuid = currentDeviceUUIDStr, - uuid != "00000000-0000-0000-0000-000000000001" { - - pairingFileURL = docPathUrl.appendingPathComponent( - "DeviceLibrary/Pairings/\(uuid).mobiledevicepairing" - ) - } else { - pairingFileURL = docPathUrl.appendingPathComponent("pairingFile.plist") - } - - return pairingFileURL.path() + URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path() } private var pairingExists: Bool { @@ -122,241 +37,104 @@ struct LocationSimulationView: View { } private var deviceIP: String { - UserDefaults.standard.string(forKey: "TunnelDeviceIP") ?? "10.7.0.1" + let stored = UserDefaults.standard.string(forKey: "customTargetIP") ?? "" + return stored.isEmpty ? "10.7.0.1" : stored } var body: some View { - NavigationStack { - ZStack { - ThemedBackground(style: backgroundStyle) - .ignoresSafeArea() - - ScrollView { - VStack(spacing: 20) { - searchCard - mapCard - actionsCard + ZStack(alignment: .bottom) { + MapReader { proxy in + Map(position: $position) { + if let coordinate { + Marker("Pin", coordinate: coordinate) + .tint(.red) } - .padding(.horizontal, 20) - .padding(.vertical, 24) - } - - if showKeepOpenAlert { - CustomErrorView( - title: "Keep StikDebug Open", - message: "Location simulation stops if the app is backgrounded. Keep StikDebug in the foreground while testing.", - onDismiss: { showKeepOpenAlert = false }, - primaryButtonText: "OK", - showSecondaryButton: false, - messageType: .info - ) - .transition(.opacity.combined(with: .scale)) - .zIndex(1) - } - } - .navigationTitle("Location Simulator") - .onDisappear { - stopResendLoop() - endBackgroundTask() - dismissPiPSession() - } - .onChange(of: enablePiP) { _, newValue in - if !newValue { - pipPresented = false } - } - } - .pipify(isPresented: Binding( - get: { pipPresented && enablePiP }, - set: { pipPresented = $0 } - )) { - LocationSpoofingPiPView(state: pipState) - } - } - - private var searchCard: some View { - MaterialCard { - VStack(alignment: .leading, spacing: 12) { - Text("Pick a Location") - .font(.headline) - .foregroundColor(.primary) - searchField - if !searchResults.isEmpty { - Divider() - ForEach(searchResults) { result in - Button(action: { select(result: result) }) { - VStack(alignment: .leading, spacing: 4) { - Text(result.name) - .font(.subheadline.weight(.semibold)) - Text(result.subtitle) - .font(.caption) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 6) - } - .buttonStyle(.plain) - if result.id != searchResults.last?.id { - Divider() - } + .mapStyle(.standard(elevation: .realistic)) + .onTapGesture { point in + if let loc = proxy.convert(point, from: .local) { + coordinate = loc } } - if !pairingExists && !isAppStoreBuild { - Label("Import a pairing file first.", systemImage: "exclamationmark.triangle") - .font(.caption) - .foregroundColor(.orange) + .mapControls { + MapCompass() } - if isAppStoreBuild { - Label("Location simulation is unavailable in App Store builds.", systemImage: "nosign") - .font(.caption) - .foregroundColor(.orange) + } + .ignoresSafeArea() + .onChange(of: coordinate) { _, new in + if let new { + position = .region(MKCoordinateRegion(center: new, latitudinalMeters: 1000, longitudinalMeters: 1000)) } } - } - } - - private var searchField: some View { - HStack { - Image(systemName: "magnifyingglass") - .foregroundColor(.secondary) - TextField("Search for a place", text: $searchQuery, onCommit: performSearch) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - } - .padding(12) - .background(Color(UIColor.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - private var mapCard: some View { - MaterialCard { - VStack(alignment: .leading, spacing: 12) { - Text("Map") - .font(.headline) - .foregroundColor(.primary) - MapSelectionView(coordinate: $coordinate) - .frame(height: 320) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color.white.opacity(0.12), lineWidth: 1) - ) + VStack(spacing: 12) { if let coord = coordinate { Text(String(format: "%.6f, %.6f", coord.latitude, coord.longitude)) - .font(.footnote) - .foregroundColor(.secondary) - } else { - Text("Long-press on the map or search above to drop a pin.") - .font(.footnote) - .foregroundColor(.secondary) - } - } - } - } - - private var actionsCard: some View { - MaterialCard { - VStack(alignment: .leading, spacing: 12) { - Text("Actions") - .font(.headline) - .foregroundColor(.primary) - HStack(spacing: 12) { - Button(action: simulate) { - Label("Simulate", systemImage: "location.fill") - .frame(maxWidth: .infinity) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + + HStack(spacing: 12) { + Button("Stop", action: clear) + .buttonStyle(.bordered) + .tint(.red) + .disabled(!pairingExists || isBusy) + + Button("Simulate Location", action: simulate) + .buttonStyle(.borderedProminent) + .disabled(!pairingExists || isBusy) } - .buttonStyle(.borderedProminent) - .disabled(isAppStoreBuild || coordinate == nil || !pairingExists) - - Button(action: clear) { - Label("Clear", systemImage: "xmark.circle") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .disabled(isAppStoreBuild || !pairingExists) - } - if !statusMessage.isEmpty { - Text(statusMessage) + } else { + Text("Tap map to drop pin") .font(.subheadline) - .foregroundColor(statusIsError ? .red : .green) + .foregroundStyle(.secondary) } - Text("Device IP: \(deviceIP)") - .font(.caption) - .foregroundColor(.secondary) } + .padding(.bottom, 24) + .padding(.horizontal, 16) } - } - - private func performSearch() { - guard !searchQuery.isEmpty else { return } - let request = MKLocalSearch.Request() - request.naturalLanguageQuery = searchQuery - MKLocalSearch(request: request).start { response, _ in - let items = response?.mapItems ?? [] - searchResults = items.map { item in - SearchResult( - name: item.name ?? "Unknown", - subtitle: item.placemark.title ?? "", - item: item - ) - } + .navigationBarTitleDisplayMode(.inline) + .onDisappear { + stopResendLoop() + endBackgroundTask() } } - private func select(result: SearchResult) { - coordinate = result.item.placemark.coordinate - statusMessage = "" - searchResults = [] - searchQuery = result.name - } - private func simulate() { - guard pairingExists else { - statusMessage = "Pairing file missing." - statusIsError = true - return - } - guard let coord = coordinate else { return } - let code = simulate_location(deviceIP, coord.latitude, coord.longitude, pairingFilePath) - if code == 0 { - statusMessage = "Simulation running…" - statusIsError = false - showKeepOpenAlert = true - beginBackgroundTask() - startResendLoop() - recordPiPEvent(status: "Simulating…", coordinate: coord) - } else { - statusMessage = "Simulation failed (code \(code))." - statusIsError = true - stopResendLoop() - endBackgroundTask() - dismissPiPSession() + guard pairingExists, let coord = coordinate, !isBusy else { return } + isBusy = true + let ip = deviceIP + let path = pairingFilePath + let lat = coord.latitude + let lon = coord.longitude + Self.locationQueue.async { + let code = simulate_location(ip, lat, lon, path) + DispatchQueue.main.async { + isBusy = false + if code == 0 { + beginBackgroundTask() + startResendLoop() + } + } } } private func clear() { - guard pairingExists else { return } - let code = clear_simulated_location() - statusMessage = code == 0 ? "Cleared simulation." : "Clear failed (code \(code))." - statusIsError = code != 0 - showKeepOpenAlert = false + guard pairingExists, !isBusy else { return } + isBusy = true stopResendLoop() - endBackgroundTask() - if code == 0 { - recordPiPEvent(status: "Simulation cleared", coordinate: nil) - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - dismissPiPSession() + Self.locationQueue.async { + _ = clear_simulated_location() + DispatchQueue.main.async { + isBusy = false + coordinate = nil + endBackgroundTask() } - } else { - dismissPiPSession() } } - + private func beginBackgroundTask() { guard backgroundTaskID == .invalid else { return } - backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "LocationSimulation") { - endBackgroundTask() - } + backgroundTaskID = UIApplication.shared.beginBackgroundTask { endBackgroundTask() } } private func endBackgroundTask() { @@ -368,12 +146,14 @@ struct LocationSimulationView: View { private func startResendLoop() { resendTimer?.invalidate() resendTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: true) { _ in - guard self.pairingExists, let coord = self.coordinate else { return } - _ = simulate_location(self.deviceIP, coord.latitude, coord.longitude, self.pairingFilePath) - self.recordPiPEvent(status: "Location refreshed", coordinate: coord) - } - if let coord = coordinate { - recordPiPEvent(status: "Simulating…", coordinate: coord) + guard let coord = coordinate else { return } + let ip = deviceIP + let path = pairingFilePath + let lat = coord.latitude + let lon = coord.longitude + Self.locationQueue.async { + _ = simulate_location(ip, lat, lon, path) + } } } @@ -381,77 +161,4 @@ struct LocationSimulationView: View { resendTimer?.invalidate() resendTimer = nil } - - private func recordPiPEvent(status: String, coordinate: CLLocationCoordinate2D?) { - DispatchQueue.main.async { - pipState.status = status - pipState.coordinate = coordinate - pipState.lastUpdated = Date() - pipPresented = true - } - } - - private func dismissPiPSession() { - DispatchQueue.main.async { - pipPresented = false - pipState.lastUpdated = nil - pipState.coordinate = nil - } - } -} - -private struct SearchResult: Identifiable { - let id = UUID() - let name: String - let subtitle: String - let item: MKMapItem -} - -private struct LocationSpoofingPiPView: View { - @ObservedObject var state: LocationSpoofingPiPState - - private static let relativeFormatter: RelativeDateTimeFormatter = { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .short - return formatter - }() - - private var coordinateText: String? { - guard let coordinate = state.coordinate else { return nil } - return String(format: "%.5f, %.5f", coordinate.latitude, coordinate.longitude) - } - - private var lastUpdatedText: String? { - guard let lastUpdated = state.lastUpdated else { return nil } - let label = Self.relativeFormatter.localizedString(for: lastUpdated, relativeTo: Date()) - return "Last send \(label)" - } - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - Text(state.status) - .font(.headline) - .foregroundColor(.white) - if let coordinateText { - Text(coordinateText) - .font(.subheadline.monospaced()) - .foregroundColor(.white.opacity(0.9)) - } - if let lastUpdatedText { - Text(lastUpdatedText) - .font(.caption) - .foregroundColor(.white.opacity(0.8)) - } - Spacer() - Text("Location Spoofing") - .font(.caption2) - .foregroundColor(.white.opacity(0.7)) - } - .padding() - .frame(width: 280, height: 150, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.black.opacity(0.65)) - ) - } } diff --git a/StikJIT/Views/ProcessInspectorView.swift b/StikJIT/Views/ProcessInspectorView.swift index f275e5b4..08217b2e 100644 --- a/StikJIT/Views/ProcessInspectorView.swift +++ b/StikJIT/Views/ProcessInspectorView.swift @@ -9,32 +9,22 @@ import SwiftUI struct ProcessInspectorView: View { @StateObject private var viewModel = ProcessInspectorViewModel() - @Environment(\.themeExpansionManager) private var themeExpansion - @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue @State private var killCandidate: ProcessInfoEntry? @State private var killConfirmTask: Task? - - private var backgroundStyle: BackgroundStyle { - themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle - } - - + var body: some View { NavigationStack { - ZStack { - ThemedBackground(style: backgroundStyle) - .ignoresSafeArea() - content - } - .navigationTitle("Process Inspector") - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button(action: viewModel.refresh) { - Label("Refresh", systemImage: "arrow.clockwise") + content + .navigationTitle("Process Inspector") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: viewModel.refresh) { + Label("Refresh", systemImage: "arrow.clockwise") + } + .disabled(viewModel.isRefreshing) } - .disabled(viewModel.isRefreshing) } - } + .searchable(text: $viewModel.searchText, placement: .navigationBarDrawer(displayMode: .always)) } .task { await viewModel.startAutoRefresh() @@ -50,7 +40,7 @@ struct ProcessInspectorView: View { ) } } - + @ViewBuilder private var content: some View { if let error = viewModel.errorMessage { @@ -60,66 +50,26 @@ struct ProcessInspectorView: View { .foregroundStyle(.orange) Text(error) .multilineTextAlignment(.center) - .foregroundColor(.primary) - Button("Try Again") { - viewModel.refresh() - } - .buttonStyle(.borderedProminent) + .foregroundStyle(.primary) + Button("Try Again") { viewModel.refresh() } + .buttonStyle(.borderedProminent) } .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - ScrollView { - VStack(spacing: 16) { - statsCard - processesCard - } - .padding(.horizontal, 20) - .padding(.top, 20) - .padding(.bottom, 30) - } - .refreshable { - viewModel.refresh() - } - .searchable(text: $viewModel.searchText, placement: .navigationBarDrawer(displayMode: .always)) - } - } -} - -private extension ProcessInspectorView { - var statsCard: some View { - MaterialCard { - VStack(alignment: .leading, spacing: 12) { - Text("Overview") - .font(.headline) - .foregroundColor(.primary) - VStack(alignment: .leading, spacing: 8) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Total Processes") - .font(.caption) - .foregroundColor(.secondary) - Text("\(viewModel.processes.count)") - .font(.title2.bold()) - } - Spacer() + List { + Section("Overview") { + LabeledContent("Total Processes") { + Text("\(viewModel.processes.count)") + .font(.title2.bold()) } } - } - } - } - - var processesCard: some View { - MaterialCard { - VStack(alignment: .leading, spacing: 12) { - Text("Processes") - .font(.headline) - .foregroundColor(.primary) - if viewModel.filteredProcesses.isEmpty { - Text("No matching processes.") - .font(.caption) - .foregroundColor(.secondary) - } else { - LazyVStack(spacing: 0) { + Section("Processes") { + if viewModel.filteredProcesses.isEmpty { + Text("No matching processes.") + .font(.caption) + .foregroundStyle(.secondary) + } else { ForEach(viewModel.filteredProcesses) { process in ProcessRow( process: process, @@ -127,19 +77,17 @@ private extension ProcessInspectorView { isConfirming: killCandidate?.pid == process.pid, onKillTap: { handleKillTap(for: $0) } ) - .padding(.vertical, 6) - - if process.id != viewModel.filteredProcesses.last?.id { - Divider() - .background(Color.white.opacity(0.1)) - } } } } } + .listStyle(.insetGrouped) + .refreshable { viewModel.refresh() } } } - +} + +private extension ProcessInspectorView { func handleKillTap(for process: ProcessInfoEntry) { if killCandidate?.pid == process.pid { killConfirmTask?.cancel() diff --git a/StikJIT/Views/ProfileView.swift b/StikJIT/Views/ProfileView.swift index d783db70..3e994f28 100644 --- a/StikJIT/Views/ProfileView.swift +++ b/StikJIT/Views/ProfileView.swift @@ -132,52 +132,106 @@ struct ProfileView: View { @State private var removeTargetName: String = "" @State private var removeTargetUUID: String = "" - @AppStorage("customAccentColor") private var customAccentColorHex: String = "" - @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 { themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue } var body: some View { NavigationStack { - ZStack { - ThemedBackground(style: backgroundStyle) - .ignoresSafeArea() - ScrollView { - VStack(spacing: 20) { - infoCard + List { + if working && entries.isEmpty { + Section { + HStack { + Spacer() + ProgressView("Loading...") + Spacer() + } + } + } else if entries.isEmpty && notMatchedProfiles.isEmpty { + Section { + Text("No profiles found.") + .foregroundStyle(.secondary) } - .padding(.horizontal, 20) - .padding(.vertical, 30) - } - if alert { - CustomErrorView(title: alertTitle, - message: alertMsg, - onDismiss: { alert = false }, - messageType: alertSuccess ? .success : .error) } - if confirmRemove { - CustomErrorView( - title: "Confirm Removal", - message: "Remove profile for \(removeTargetName) (UUID: \(removeTargetUUID))?\n**Apps associated with this profile may become unavailable.**", - onDismiss: { confirmRemove = false }, - showButton: true, - primaryButtonText: "Remove", - secondaryButtonText: "Cancel", - onPrimaryButtonTap: { - Task { await removeProfile(uuid: removeTargetUUID) } - }, - onSecondaryButtonTap: { - // Just dismiss - }, - showSecondaryButton: true, - messageType: .info - ) + ForEach(entries) { entry in + Section { + // Header/Status Row + VStack(alignment: .leading, spacing: 4) { + if let match = entry.bestMatchingProfile { + HStack { + Image(systemName: "clock") + Text("Expires: \(match.profile.formattedDate)") + } + .foregroundStyle(match.profile.dateColor) + .font(.subheadline) + } else { + HStack { + Image(systemName: "exclamationmark.triangle") + Text("No matching profile") + } + .font(.subheadline) + .foregroundColor(.refreshRed) + } + + Text(entry.id) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + + // Profiles + if let recent = entry.mostRecentProfile { + profileRow(match: recent, isMostRecent: true) + } + + if let best = entry.bestMatchingProfile, best.profile.uuid != entry.mostRecentProfile?.profile.uuid { + profileRow(match: best, isMostRecent: false) + } + + if entry.profileMatches.count > 1 { + let showMore = expandedApps.contains(entry.id) + let extraProfiles = entry.profileMatches.dropFirst(recentAndBestCount(for: entry)) + + if !extraProfiles.isEmpty { + if showMore { + ForEach(extraProfiles, id: \.profile.uuid) { match in + profileRow(match: match, isMostRecent: false) + } + } + + Button { + withAnimation { + if showMore { expandedApps.remove(entry.id) } + else { expandedApps.insert(entry.id) } + } + } label: { + Label(showMore ? "Hide older profiles" : "Show \(extraProfiles.count) older profiles", + systemImage: showMore ? "chevron.up" : "chevron.down") + .font(.caption) + .foregroundStyle(.blue) + } + } + } + } header: { + Text(entry.name) + } } + if !notMatchedProfiles.isEmpty { + Section("Other Profiles") { + ForEach(notMatchedProfiles) { entry in + VStack(alignment: .leading) { + Text(entry.id) + .font(.caption.monospaced()) + .foregroundStyle(.primary) + } + + ForEach(entry.profileMatches, id: \.profile.uuid) { match in + profileRow(match: match, isMostRecent: false) + } + } + } + } } + .listStyle(.insetGrouped) .navigationTitle("App Expiry") .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { @@ -195,7 +249,6 @@ struct ProfileView: View { } } - .onAppear { Task { await loadData() } } .fileImporter( isPresented: $isImporterPresented, @@ -218,130 +271,18 @@ struct ProfileView: View { } } } - .preferredColorScheme(preferredScheme) - } - - // MARK: - UI Sections - - private var infoCard: some View { - VStack { - HStack { - Text("Sideloaded Apps") - .font(.subheadline) - .foregroundStyle(.secondary) - Spacer() - } - - LazyVStack(alignment: .leading, spacing: 14) { - if entries.isEmpty { - Text(working ? "Loading Apps" : "No sideloaded apps found") - } else { - ForEach(entries) { entry in - appRow(for: entry) - if entry.id != entries.last?.id { - Divider() - .background(Color.white.opacity(0.12)) - .padding(.vertical, 4) - } - } - } - - - } - .padding(20) - .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) - ) - ) - .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) - .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) - - if !notMatchedProfiles.isEmpty { - HStack { - Text("Other Profiles") - .padding(.top, 10.0) - .font(.subheadline) - .foregroundStyle(.secondary) - Spacer() - } - LazyVStack(alignment: .leading, spacing: 14) { - ForEach(notMatchedProfiles) { entry in - appRow(for: entry) - if entry.id != notMatchedProfiles.last?.id { - Divider() - .background(Color.white.opacity(0.12)) - .padding(.vertical, 4) - } - } - } - .padding(20) - .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) - ) - ) - .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) - .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) - } + .alert(alertTitle, isPresented: $alert) { + Button("OK", role: .cancel) { } + } message: { + Text(alertMsg) } - } - - private func appRow(for entry: AppProfileStatus) -> some View { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .firstTextBaseline) { - Text(entry.name) - .font(.headline) - .foregroundStyle(.primary) - Spacer() - if let match = entry.bestMatchingProfile { - Text("Expires: \(match.profile.formattedDate)") - .foregroundStyle(match.profile.dateColor) - .font(.caption) - } else { - Text("No matching profile") - .font(.caption) - .foregroundColor(.refreshRed) - } - } - Text(entry.id) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .textSelection(.enabled) - if let recent = entry.mostRecentProfile { - profileRow(match: recent, isMostRecent: true) - } - if let best = entry.bestMatchingProfile, best.profile.uuid != entry.mostRecentProfile?.profile.uuid { - profileRow(match: best, isMostRecent: false) - } - if entry.profileMatches.count > 1 { - if expandedApps.contains(entry.id) { - ForEach(entry.profileMatches.dropFirst(recentAndBestCount(for: entry)), id: \.profile.uuid) { match in - profileRow(match: match, isMostRecent: false) - } - } - Button { - withAnimation(.easeInOut(duration: 0.25)) { - if expandedApps.contains(entry.id) { - expandedApps.remove(entry.id) - } else { - expandedApps.insert(entry.id) - } - } - } label: { - Label(expandedApps.contains(entry.id) ? "Hide older profiles" : "Show older profiles", - systemImage: expandedApps.contains(entry.id) ? "chevron.up" : "chevron.down") - .font(.caption) - .labelStyle(.titleAndIcon) - .foregroundStyle(.secondary) - } + .alert("Confirm Removal", isPresented: $confirmRemove) { + Button("Remove", role: .destructive) { + Task { await removeProfile(uuid: removeTargetUUID) } } + Button("Cancel", role: .cancel) { } + } message: { + Text("Remove profile for \(removeTargetName) (UUID: \(removeTargetUUID))?\nApps associated with this profile may become unavailable.") } } @@ -387,8 +328,8 @@ struct ProfileView: View { .textSelection(.enabled) } Spacer() - HStack { - profileActionButton(icon: "square.and.arrow.down", color: accentColor) { + HStack(spacing: 16) { + profileActionButton(icon: "square.and.arrow.down", color: .blue) { saveProfile(profile: match.profile) } profileActionButton(icon: "trash", color: .refreshRed) { @@ -409,19 +350,7 @@ struct ProfileView: View { } } } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color.white.opacity(0.05)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(match.missingEntitlements.isEmpty ? Color.white.opacity(0.1) : Color.refreshRed.opacity(0.6), lineWidth: 1) - ) - ) - .background( - match.missingEntitlements.isEmpty ? Color.clear : Color.refreshRed.opacity(0.08) - ) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .padding(.vertical, 4) } // MARK: - Data loading diff --git a/StikJIT/Views/SettingsView.swift b/StikJIT/Views/SettingsView.swift index 2c593cf5..620d2641 100644 --- a/StikJIT/Views/SettingsView.swift +++ b/StikJIT/Views/SettingsView.swift @@ -13,59 +13,30 @@ struct SettingsView: View { @AppStorage("enableAdvancedOptions") private var enableAdvancedOptions = false @AppStorage("enableAdvancedBetaOptions") private var enableAdvancedBetaOptions = false @AppStorage("enableTesting") private var enableTesting = false - @AppStorage("enablePiP") private var enablePiP = false @AppStorage(UserDefaults.Keys.txmOverride) private var overrideTXMDetection = false - @AppStorage("customAccentColor") private var customAccentColorHex: String = "" - @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue + @AppStorage("keepAliveAudio") private var keepAliveAudio = true + @AppStorage("keepAliveLocation") private var keepAliveLocation = true + @AppStorage("customTargetIP") private var customTargetIP = "" @AppStorage(TabConfiguration.storageKey) private var enabledTabIdentifiers = TabConfiguration.defaultRawValue @AppStorage("primaryTabSelection") private var tabSelection = TabConfiguration.defaultIDs.first ?? "home" - @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 isAppStoreBuild: Bool { - #if APPSTORE - return true - #else - return false - #endif - } @State private var isShowingPairingFilePicker = false - @Environment(\.colorScheme) private var colorScheme - - @State private var showIconPopover = false @State private var showPairingFileMessage = 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 showDDIConfirmation = false @State private var isRedownloadingDDI = false @State private var ddiDownloadProgress: Double = 0.0 @State private var ddiStatusMessage: String = "" @State private var ddiResultMessage: (text: String, isError: Bool)? - @State private var showingDisplayView = false - @State private var tabSelectionMessage: String? - + private var appVersion: String { let marketingVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" return marketingVersion } - private var accentColor: Color { - themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue - } - - private var currentThemeName: String { - AppTheme(rawValue: appThemeRaw)?.displayName ?? "Default" - } - - private var accentColorDescription: String { - customAccentColorHex.isEmpty ? "System Blue" : customAccentColorHex.uppercased() - } struct TabOption: Identifiable { let id: String let title: String @@ -74,146 +45,185 @@ struct SettingsView: View { let isBeta: Bool } - private let developerProfiles: [String: String] = [ - "Stephen": "https://github.com/StephenDev0.png", - "jkcoxson": "https://github.com/jkcoxson.png", - "Stossy11": "https://github.com/Stossy11.png", - "Neo": "https://github.com/neoarz.png", - "Se2crid": "https://github.com/Se2crid.png", - "Huge_Black": "https://github.com/HugeBlack.png", - "Wynwxst": "https://github.com/Wynwxst.png" - ] - private var tabOptions: [TabOption] { var options: [TabOption] = [ TabOption(id: "home", title: "Home", detail: "Dashboard overview", icon: "house", isBeta: false), - TabOption(id: "console", title: "Console", detail: "Live device logs", icon: "terminal", isBeta: false), - TabOption(id: "scripts", title: "Scripts", detail: "Manage automation scripts", icon: "scroll", isBeta: false) + TabOption(id: "scripts", title: "Scripts", detail: "Manage automation scripts", icon: "scroll", isBeta: false), + TabOption(id: "tools", title: "Tools", detail: "Access additional tools", icon: "wrench.and.screwdriver", isBeta: false) ] options.append(TabOption(id: "deviceinfo", title: "Device Info", detail: "View detailed device metadata", icon: "iphone.and.arrow.forward", isBeta: false)) options.append(TabOption(id: "profiles", title: "App Expiry", detail: "Check app expiration date, install/remove profiles", icon: "calendar.badge.clock", isBeta: false)) - - if FeatureFlags.showBetaTabs { - options.append(TabOption(id: "processes", title: "Processes", detail: "Inspect running apps", icon: "rectangle.stack.person.crop", isBeta: true)) - options.append(TabOption(id: "devicelibrary", title: "Devices", detail: "Manage external devices", icon: "list.bullet.rectangle", isBeta: true)) - if FeatureFlags.isLocationSpoofingEnabled && !isAppStoreBuild { - options.append(TabOption(id: "location", title: "Location Sim", detail: "Sideload only", icon: "location", isBeta: true)) - } - } + options.append(TabOption(id: "processes", title: "Processes", detail: "Inspect running apps", icon: "rectangle.stack.person.crop", isBeta: false)) + options.append(TabOption(id: "location", title: "Location Sim", detail: "Sideload only", icon: "location", isBeta: false)) return options } var body: some View { NavigationStack { - ZStack { - // Subtle depth gradient background - ThemedBackground(style: backgroundStyle) - .ignoresSafeArea() - - ScrollView { - VStack(spacing: 20) { - headerCard - appearanceCard - tabCustomizationCard - pairingCard - behaviorCard - advancedCard - helpCard - versionInfo + Form { + // 1) App Header + Section { + HStack { + Spacer() + VStack(spacing: 12) { + Image("StikDebug") + .resizable().aspectRatio(contentMode: .fit) + .frame(width: 80, height: 80) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + Text("StikDebug").font(.title2.weight(.semibold)) + } + Spacer() } - .padding(.horizontal, 20) - .padding(.vertical, 30) + .listRowBackground(Color.clear) + .padding(.vertical, 8) } - - // Busy overlay while importing pairing file - if isImportingFile { - Color.black.opacity(0.35).ignoresSafeArea() - VStack(spacing: 12) { - ProgressView("Processing pairing file…") - VStack(spacing: 8) { - GeometryReader { geometry in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 6) - .fill(Color(UIColor.tertiarySystemFill)) - .frame(height: 8) - - RoundedRectangle(cornerRadius: 6) - .fill(Color.green) - .frame(width: geometry.size.width * CGFloat(importProgress), height: 8) - .animation(.linear(duration: 0.3), value: importProgress) - } - } - .frame(height: 8) - Text("\(Int(importProgress * 100))%") - .font(.caption) - .foregroundColor(.secondary) + + // 2) Profile + Section("Profile") { + HStack { + Text("Username") + Spacer() + TextField("User", text: $username) + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + } + } + + // 3) Pairing File + Section("Pairing File") { + Button { isShowingPairingFilePicker = true } label: { + Label("Import Pairing File", systemImage: "doc.badge.plus") + } + if showPairingFileMessage && !isImportingFile { + Label("Imported successfully", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } + + // 5) Background Keep-Alive + Section { + Toggle(isOn: $keepAliveAudio) { + VStack(alignment: .leading, spacing: 2) { + Text("Silent Audio") + Text("Plays inaudible audio so iOS keeps the app running.") + .font(.caption).foregroundStyle(.secondary) + } + } + .onChange(of: keepAliveAudio) { _, enabled in + if enabled { BackgroundAudioManager.shared.start() } + else { BackgroundAudioManager.shared.stop() } + } + + Toggle(isOn: $keepAliveLocation) { + VStack(alignment: .leading, spacing: 2) { + Text("Background Location") + Text("Uses low-accuracy location to stay alive even when another app plays audio.") + .font(.caption).foregroundStyle(.secondary) } - .padding(.top, 6) } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) - ) - ) - .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) + .onChange(of: keepAliveLocation) { _, enabled in + if enabled { BackgroundLocationManager.shared.start() } + else { BackgroundLocationManager.shared.stop() } + } + } header: { + Text("Background Keep-Alive") + } footer: { + Text("For Background Location to work reliably, go to **Settings → Privacy & Security → Location Services → StikDebug** and select **Always**.") } - - // Success toast after import - if let pairingStatusMessage, - showPairingFileMessage, - !isImportingFile { - VStack { + + // 6) Behavior + Section("Behavior") { + Toggle(isOn: $overrideTXMDetection) { + VStack(alignment: .leading, spacing: 2) { + Text("Always Run Scripts") + Text("Treats device as TXM-capable to bypass hardware checks.") + .font(.caption).foregroundStyle(.secondary) + } + } + } + + // 7) Advanced + Section("Advanced") { + HStack { + Text("Target Device IP") Spacer() - Text(pairingStatusMessage) - .font(.footnote.weight(.semibold)) - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(.ultraThinMaterial, in: Capsule()) - .overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 1)) - .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 3) - .transition(.move(edge: .bottom).combined(with: .opacity)) - .padding(.bottom, 30) + TextField("10.7.0.1", text: $customTargetIP) + .multilineTextAlignment(.trailing) + .keyboardType(.decimalPad) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + } + } + } + Button { openAppFolder() } label: { + Label("App Folder", systemImage: "folder") + }.foregroundStyle(.primary) + Button { showDDIConfirmation = true } label: { + Label("Redownload DDI", systemImage: "arrow.down.circle") + }.foregroundStyle(.primary).disabled(isRedownloadingDDI) + if isRedownloadingDDI { + VStack(alignment: .leading, spacing: 4) { + ProgressView(value: ddiDownloadProgress, total: 1.0) + Text(ddiStatusMessage).font(.caption).foregroundStyle(.secondary) + } + } else if let result = ddiResultMessage { + Text(result.text).font(.caption).foregroundStyle(result.isError ? .red : .green) + } + } + + // 7) Help + Section("Help") { + Link(destination: URL(string: "https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md")!) { + Label("Pairing File Guide", systemImage: "questionmark.circle") } - .animation(.easeInOut(duration: 0.25), value: showPairingFileMessage) + Link(destination: URL(string: "https://apps.apple.com/us/app/localdevvpn/id6755608044")!) { + Label("Download LocalDevVPN", systemImage: "arrow.down.circle") + } + Link(destination: URL(string: "https://discord.gg/qahjXNTDwS")!) { + Label("Discord Support", systemImage: "bubble.left.and.bubble.right") + } + } + + // 8) Version footer + Section { + Text(versionFooter) + .font(.footnote).foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .listRowBackground(Color.clear) } } .navigationTitle("Settings") } - // Match controls to the active accent color (defaults to blue) - .tint(accentColor) - .preferredColorScheme(preferredScheme) - .fileImporter( + .fileImporter( isPresented: $isShowingPairingFilePicker, - allowedContentTypes: [UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, .propertyList], + allowedContentTypes: [UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, UTType(filenameExtension: "mobiledevicepair", conformingTo: .data)!, .propertyList], allowsMultipleSelection: false ) { result in switch result { case .success(let urls): guard let url = urls.first else { return } - + let fileManager = FileManager.default let accessing = url.startAccessingSecurityScopedResource() - + if fileManager.fileExists(atPath: url.path) { do { if fileManager.fileExists(atPath: URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path) { try fileManager.removeItem(at: URL.documentsDirectory.appendingPathComponent("pairingFile.plist")) } - + try fileManager.copyItem(at: url, to: URL.documentsDirectory.appendingPathComponent("pairingFile.plist")) - print("File copied successfully!") - DispatchQueue.main.async { isImportingFile = true importProgress = 0.0 pairingStatusMessage = nil showPairingFileMessage = false } - + let progressTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { timer in DispatchQueue.main.async { if importProgress < 1.0 { @@ -224,24 +234,20 @@ struct SettingsView: View { } } } - + RunLoop.current.add(progressTimer, forMode: .common) DispatchQueue.main.async { startHeartbeatInBackground() } - - } catch { - print("Error copying file: \(error)") - } - } else { - print("Source file does not exist.") + + } catch { } } - + if accessing { url.stopAccessingSecurityScopedResource() } - case .failure(let error): - print("Failed to import file: \(error)") + case .failure: + break } } .confirmationDialog("Redownload DDI Files?", isPresented: $showDDIConfirmation, titleVisibility: .visible) { @@ -252,325 +258,46 @@ struct SettingsView: View { } message: { Text("Existing DDI files will be removed before downloading fresh copies.") } + .overlay { if isImportingFile { importBusyOverlay } } } - - // MARK: - Cards - - private var headerCard: some View { - glassCard { - VStack(spacing: 16) { - VStack { - Image("StikDebug") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 80, height: 80) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.white.opacity(0.12), lineWidth: 1) - ) - } - Text("StikDebug") - .font(.title2.weight(.semibold)) - .foregroundColor(.primary) - } - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .center) - } - } - - private var appearanceCard: some View { - glassCard { - 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("Customize Display") - .foregroundColor(.primary.opacity(0.85)) - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 14)) - .foregroundColor(.white) - } - .padding(.vertical, 8) - } - } - .padding(4) - } - .sheet(isPresented: $showingDisplayView) { - if let manager = themeExpansion { - DisplayView().themeExpansionManager(manager) - } else { - DisplayView() - } - } - } - - private var tabCustomizationCard: some View { - let selection = selectedTabIDs - let pinnedOptions = selectedTabIDs.compactMap { id in - tabOptions.first(where: { $0.id == id }) - } - let unpinnedOptions = tabOptions.filter { !selection.contains($0.id) } - return glassCard { - VStack(alignment: .leading, spacing: 16) { - Text("Tab Bar") - .font(.headline) - .foregroundColor(.primary) - Text("Pick up to \(TabConfiguration.maxSelectableTabs) tabs to pin. Settings is always available as the final tab.") - .font(.caption) - .foregroundColor(.secondary) - ForEach(pinnedOptions) { option in - TabRow(option: option, - isPinned: true, - isFirst: pinnedOptions.first?.id == option.id, - isLast: pinnedOptions.last?.id == option.id, - isBeta: option.isBeta, - onMove: { moveTab(option.id, offset: $0) }, - onToggle: { toggleTabOption(option, enable: $0) }, - onSelect: { if $0 { tabSelection = option.id }; switchToTab(option.id) }) - } - if !unpinnedOptions.isEmpty { - Divider() - ForEach(unpinnedOptions) { option in - TabRow(option: option, - isPinned: false, - isFirst: false, - isLast: false, - isBeta: option.isBeta, - onMove: { _ in }, - onToggle: { toggleTabOption(option, enable: $0) }, - onSelect: { _ in switchToTab(option.id) }) + @ViewBuilder + private var importBusyOverlay: some View { + Color.black.opacity(0.35).ignoresSafeArea() + VStack(spacing: 12) { + ProgressView("Processing pairing file…") + VStack(spacing: 8) { + GeometryReader { geometry in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 6) + .fill(Color(UIColor.tertiarySystemFill)) + .frame(height: 8) + RoundedRectangle(cornerRadius: 6) + .fill(Color.green) + .frame(width: geometry.size.width * CGFloat(importProgress), height: 8) + .animation(.linear(duration: 0.3), value: importProgress) } } - Text("\(selection.count) / \(TabConfiguration.maxSelectableTabs) slots used") + .frame(height: 8) + Text("\(Int(importProgress * 100))%") .font(.caption) - .foregroundColor(.secondary) - if let message = tabSelectionMessage { - Text(message) - .font(.caption) - .foregroundColor(.orange) - } - } - .padding(4) - } - } - - private var pairingCard: some View { - glassCard { - VStack(alignment: .leading, spacing: 14) { - Text("Pairing File") - .font(.headline) - .foregroundColor(.primary) - - Button { - isShowingPairingFilePicker = true - } label: { - HStack { - Image(systemName: "doc.badge.plus") - .font(.system(size: 18)) - Text("Import New Pairing File") - .fontWeight(.medium) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .foregroundColor(accentColor.contrastText()) - .background(accentColor, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color.white.opacity(0.2), lineWidth: 0.5) - ) - } - if showPairingFileMessage && !isImportingFile { - HStack { - Image(systemName: "checkmark.circle.fill").foregroundColor(.green) - Text("Pairing file successfully imported") - .font(.callout) - .foregroundColor(.green) - Spacer() - } - .padding(.vertical, 6) - .transition(.opacity) - } - } - } - } - - private var behaviorCard: some View { - glassCard { - VStack(alignment: .leading, spacing: 16) { - Text("Behavior") - .font(.headline) - .foregroundColor(.primary) - - Toggle("Picture in Picture", isOn: $enablePiP) - .tint(accentColor) - Toggle(isOn: $overrideTXMDetection) { - VStack(alignment: .leading, spacing: 2) { - Text("Always Run Scripts") - .font(.subheadline.weight(.semibold)) - .foregroundColor(.primary.opacity(0.9)) - Text("Treat this device as TXM-capable to bypass hardware checks.") - .font(.caption) - .foregroundColor(.secondary) - } - } - .tint(accentColor) - } - .onChange(of: enableAdvancedOptions) { _, newValue in - if !newValue { - enablePiP = false - enableAdvancedBetaOptions = false - enableTesting = false - } - } - .onChange(of: enableAdvancedBetaOptions) { _, newValue in - if !newValue { - enableTesting = false - } - } - } - } - - private var advancedCard: some View { - glassCard { - VStack(alignment: .leading, spacing: 14) { - Text("Advanced") - .font(.headline) - .foregroundColor(.primary) - - Button(action: { openAppFolder() }) { - HStack { - Image(systemName: "folder") - .font(.system(size: 18)) - .foregroundColor(.primary.opacity(0.8)) - Text("App Folder") - .foregroundColor(.primary.opacity(0.8)) - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 14)) - .foregroundColor(.white) - } - .padding(.vertical, 8) - } - Button(action: { showDDIConfirmation = true }) { - HStack { - Image(systemName: "arrow.down.circle") - .font(.system(size: 18)) - .foregroundColor(.primary.opacity(0.8)) - Text("Redownload DDI") - .foregroundColor(.primary.opacity(0.8)) - Spacer() - } - .padding(.vertical, 8) - } - .disabled(isRedownloadingDDI) - - if isRedownloadingDDI { - VStack(alignment: .leading, spacing: 6) { - ProgressView(value: ddiDownloadProgress, total: 1.0) - .progressViewStyle(.linear) - .tint(accentColor) - Text(ddiStatusMessage) - .font(.caption) - .foregroundColor(.secondary) - } - } else if let result = ddiResultMessage { - Text(result.text) - .font(.caption) - .foregroundColor(result.isError ? .red : .green) - } + .foregroundStyle(.secondary) } + .padding(.top, 6) } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) } - private var helpCard: some View { - glassCard { - VStack(alignment: .leading, spacing: 14) { - Text("Help") - .font(.headline) - .foregroundColor(.primary) - - Button(action: { - if let url = URL(string: "https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md") { - UIApplication.shared.open(url) - } - }) { - HStack { - Image(systemName: "questionmark.circle") - .font(.system(size: 18)) - .foregroundColor(.primary.opacity(0.8)) - Text("Pairing File Guide") - .foregroundColor(.primary.opacity(0.8)) - Spacer() - } - .padding(.vertical, 8) - } - - Button(action: { - if let url = URL(string: "https://apps.apple.com/us/app/localdevvpn/id6755608044") { - UIApplication.shared.open(url) - } - }) { - HStack { - Image(systemName: "arrow.down.circle") - .font(.system(size: 18)) - .foregroundColor(.primary.opacity(0.8)) - Text("Download LocalDevVPN") - .foregroundColor(.primary.opacity(0.8)) - Spacer() - } - .padding(.vertical, 8) - } - - Button(action: { - if let url = URL(string: "https://discord.gg/qahjXNTDwS") { - UIApplication.shared.open(url) - } - }) { - HStack { - Image(systemName: "questionmark.circle") - .font(.system(size: 18)) - .foregroundColor(.primary.opacity(0.8)) - Text("Need support? Join the Discord!") - .foregroundColor(.primary.opacity(0.8)) - Spacer() - } - .padding(.vertical, 8) - } - - } - } - } - - private var versionInfo: some View { + private var versionFooter: String { let processInfo = ProcessInfo.processInfo let txmLabel: String if processInfo.isTXMOverridden { @@ -578,175 +305,16 @@ struct SettingsView: View { } else { txmLabel = processInfo.hasTXM ? "TXM" : "Non TXM" } - return HStack { - Spacer() - Text("Version \(appVersion) • iOS \(UIDevice.current.systemVersion) • \(txmLabel)") - .font(.footnote) - .foregroundColor(.secondary) - Spacer() - } - .padding(.top, 6) - } - - // MARK: - Helpers (UI + logic) - - private var selectedTabIDs: [String] { - TabConfiguration.sanitize(raw: enabledTabIdentifiers) - } - - private func toggleTabOption(_ option: TabOption, enable: Bool) { - var ids = selectedTabIDs - if enable { - guard !ids.contains(option.id) else { return } - guard ids.count < TabConfiguration.maxSelectableTabs else { - tabSelectionMessage = "You can only pin \(TabConfiguration.maxSelectableTabs) tabs besides Settings." - return - } - ids.append(option.id) - } else { - ids.removeAll { $0 == option.id } - if ids.isEmpty { - ids = TabConfiguration.defaultIDs - } - } - tabSelectionMessage = nil - enabledTabIdentifiers = TabConfiguration.serialize(ids) - } - - private func moveTab(_ id: String, offset: Int) { - var ids = selectedTabIDs - guard let currentIndex = ids.firstIndex(of: id) else { return } - let targetIndex = max(0, min(ids.count - 1, currentIndex + offset)) - guard currentIndex != targetIndex else { return } - let element = ids.remove(at: currentIndex) - ids.insert(element, at: targetIndex) - enabledTabIdentifiers = TabConfiguration.serialize(ids) + return "Version \(appVersion) • iOS \(UIDevice.current.systemVersion) • \(txmLabel)" } - private func switchToTab(_ id: String) { - NotificationCenter.default.post(name: .switchToTab, object: id) - } - - private struct TabRow: View { - let option: TabOption - let isPinned: Bool - let isFirst: Bool - let isLast: Bool - let isBeta: Bool - let onMove: (Int) -> Void - let onToggle: (Bool) -> Void - let onSelect: (Bool) -> Void - - var body: some View { - HStack(spacing: 12) { - Button { - onSelect(isPinned) - } label: { - HStack(alignment: .center, spacing: 0) { - Image(systemName: option.icon) - .frame(width: 24, height: 24) - .foregroundColor(.primary.opacity(0.8)) - VStack(alignment: .leading, spacing: 2) { - Text(option.title) - .font(.subheadline.weight(.semibold)) - Text(option.detail) - .font(.caption) - .foregroundColor(.secondary) - } - .padding(.leading, 8) - if isBeta { - Spacer(minLength: 12) - Text("BETA") - .font(.caption2.weight(.bold)) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .foregroundColor(.orange) - .background( - Capsule() - .fill(Color.orange.opacity(0.15)) - ) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.plain) + // MARK: - Business Logic - if isPinned { - HStack(spacing: 8) { - Button { - onMove(-1) - } label: { - Image(systemName: "chevron.up") - } - .disabled(isFirst) - - Button { - onMove(1) - } label: { - Image(systemName: "chevron.down") - } - .disabled(isLast) - } - .buttonStyle(.borderless) - } - - Toggle(isOn: Binding( - get: { isPinned }, - set: { newValue in onToggle(newValue) } - )) { - EmptyView() - } - .labelsHidden() - } - } - } - - private func glassCard(@ViewBuilder content: () -> Content) -> some View { - MaterialCard { - content() - } - } - - private func changeAppIcon(to iconName: String) { - selectedAppIcon = iconName - UIApplication.shared.setAlternateIconName(iconName == "AppIcon" ? nil : iconName) { error in - if let error = error { - print("Error changing app icon: \(error.localizedDescription)") - } - } - } - - private func iconButton(_ label: String, icon: String) -> some View { - Button(action: { - changeAppIcon(to: icon) - showIconPopover = false - }) { - HStack { - Image(uiImage: UIImage(named: icon) ?? UIImage()) - .resizable() - .frame(width: 24, height: 24) - .clipShape(RoundedRectangle(cornerRadius: 5)) - Text(label) - .foregroundColor(.primary) - Spacer() - } - .padding() - .background(Color(UIColor.secondarySystemBackground)) - .cornerRadius(10) - } - .padding(.horizontal) - } - private func openAppFolder() { - if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { - let path = documentsURL.absoluteString.replacingOccurrences(of: "file://", with: "shareddocuments://") - if let url = URL(string: path) { - UIApplication.shared.open(url, options: [:]) { success in - if !success { - print("Failed to open app folder") - } - } - } + guard let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } + let path = documentsURL.absoluteString.replacingOccurrences(of: "file://", with: "shareddocuments://") + if let url = URL(string: path) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) } } @@ -792,85 +360,71 @@ struct SettingsView: View { } } -// MARK: - Helper Components +// MARK: - Tab Customization -struct SettingsCard: View { - let content: Content - - init(@ViewBuilder content: () -> Content) { - self.content = content() +struct TabCustomizationView: View { + let tabOptions: [SettingsView.TabOption] + @Binding var enabledTabIdentifiers: String + @Binding var tabSelection: String + + private var selectedIDs: [String] { + TabConfiguration.sanitize(raw: enabledTabIdentifiers) } - - var body: some View { - content - .background(Color(UIColor.secondarySystemBackground)) - .cornerRadius(16) - .shadow(color: Color.black.opacity(0.08), radius: 3, x: 0, y: 2) + + private var pinnedOptions: [SettingsView.TabOption] { + selectedIDs.compactMap { id in tabOptions.first(where: { $0.id == id }) } } -} -struct InfoRow: View { - var title: String - var value: String - - var body: some View { - HStack { - Text(title) - .foregroundColor(.secondary) - Spacer() - Text(value) - .foregroundColor(.primary) - .fontWeight(.medium) - } - .padding(.vertical, 4) + private var availableOptions: [SettingsView.TabOption] { + tabOptions.filter { !selectedIDs.contains($0.id) } } -} -struct LinkRow: View { - var icon: String - var title: String - var url: String - var body: some View { - Button(action: { - if let url = URL(string: url) { - UIApplication.shared.open(url) + List { + Section { + ForEach(pinnedOptions) { option in + HStack { + Label(option.title, systemImage: option.icon) + } + } + .onMove { indices, newOffset in + var ids = selectedIDs + ids.move(fromOffsets: indices, toOffset: newOffset) + enabledTabIdentifiers = TabConfiguration.serialize(ids) + } + } header: { + Text("Pinned") + } footer: { + Text("Settings is fixed as the 4th tab.") } - }) { - HStack(alignment: .center) { - Text(title) - .foregroundColor(.secondary) - Spacer() - Image(systemName: icon) - .font(.system(size: 18)) - .foregroundColor(.white) - .frame(width: 24) + + if !availableOptions.isEmpty { + Section("Available") { + ForEach(availableOptions) { option in + Button { + var ids = selectedIDs + guard ids.count < TabConfiguration.maxSelectableTabs else { return } + ids.append(option.id) + enabledTabIdentifiers = TabConfiguration.serialize(ids) + } label: { + HStack { + Label(option.title, systemImage: option.icon) + } + } + .foregroundStyle(.primary) + } + } } } - .padding(.vertical, 8) + .navigationTitle("Tab Bar") + .toolbar { + EditButton() + } } } struct ConsoleLogsView_Preview: PreviewProvider { static var previews: some View { ConsoleLogsView() - .themeExpansionManager(ThemeExpansionManager(previewUnlocked: true)) - } -} - -class FolderViewController: UIViewController { - func openAppFolder() { - let paths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true) - guard let documentsDirectory = paths.first else { return } - let containerPath = (documentsDirectory as NSString).deletingLastPathComponent - - if let folderURL = URL(string: "shareddocuments://\(containerPath)") { - UIApplication.shared.open(folderURL, options: [:]) { success in - if !success { - let regularURL = URL(fileURLWithPath: containerPath) - UIApplication.shared.open(regularURL, options: [:], completionHandler: nil) - } - } - } } } diff --git a/StikJIT/Views/SharedStubs.swift b/StikJIT/Views/SharedStubs.swift deleted file mode 100644 index 92ce984b..00000000 --- a/StikJIT/Views/SharedStubs.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// SharedStubs.swift -// StikJIT -// -// Created by Stephen on 09/12/2025. -// - -import SwiftUI -import UniformTypeIdentifiers -import UIKit - -// MARK: - Shared glass card wrapper (renamed to avoid conflicts) -struct MaterialCard: View { - let content: Content - init(@ViewBuilder content: () -> Content) { - self.content = content() - } - var body: 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) - ) - ) - .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) - .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) - } -} - -@ViewBuilder -func appGlassCard(@ViewBuilder _ content: () -> Content) -> some View { - MaterialCard { - content() - } -} - -// MARK: - Open App Folder helper - -func openAppFolder() { - let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - let controller = UIActivityViewController(activityItems: [docs], applicationActivities: nil) - controller.excludedActivityTypes = [.assignToContact, .saveToCameraRoll, .postToFacebook, .postToTwitter] - DispatchQueue.main.async { - guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let root = scene.windows.first?.rootViewController else { return } - root.present(controller, animated: true) - } -} diff --git a/StikJIT/Views/TextFieldAlert.swift b/StikJIT/Views/TextFieldAlert.swift deleted file mode 100644 index d9ddbaa4..00000000 --- a/StikJIT/Views/TextFieldAlert.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// TextAlert.swift -// StikJIT -// -// Created by s s on 2025/5/11. -// -import SwiftUI -import UIKit - -public struct TextFieldAlertModifier: ViewModifier { - - @State private var alertController: UIAlertController? - - @Binding var isPresented: Bool - - let title: String - let text: Binding - let placeholder: String - let action: (String?) -> Void - let actionCancel: (String?) -> Void - - public func body(content: Content) -> some View { - content.onChange(of: isPresented) { isPresented in - if isPresented, alertController == nil { - let alertController = makeAlertController() - self.alertController = alertController - - guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { - return - } - scene.windows.first?.rootViewController?.present(alertController, animated: true) - } else if !isPresented, let alertController = alertController { - alertController.dismiss(animated: true) - self.alertController = nil - } - } - } - - private func makeAlertController() -> UIAlertController { - let controller = UIAlertController(title: title, message: nil, preferredStyle: .alert) - controller.addTextField { - $0.placeholder = self.placeholder - $0.text = self.text.wrappedValue - $0.clearButtonMode = .always - } - controller.addAction(UIAlertAction(title: "Cancel".localized, style: .cancel) { _ in - self.actionCancel(nil) - shutdown() - }) - controller.addAction(UIAlertAction(title: "OK".localized, style: .default) { _ in - self.action(controller.textFields?.first?.text) - shutdown() - }) - return controller - } - - private func shutdown() { - isPresented = false - alertController = nil - } - -} - - -extension View { - - public func textFieldAlert( - isPresented: Binding, - title: String, - text: Binding, - placeholder: String = "", - action: @escaping (String?) -> Void, - actionCancel: @escaping (String?) -> Void - ) -> some View { - self.modifier(TextFieldAlertModifier(isPresented: isPresented, title: title, text: text, placeholder: placeholder, action: action, actionCancel: actionCancel)) - } -} diff --git a/StikJIT/Views/ToolsView.swift b/StikJIT/Views/ToolsView.swift new file mode 100644 index 00000000..f198556f --- /dev/null +++ b/StikJIT/Views/ToolsView.swift @@ -0,0 +1,50 @@ +// +// ToolsView.swift +// StikJIT +// +// Created by Stephen on 2/23/26. +// + +import SwiftUI + +struct ToolsView: View { + private struct ToolItem: Identifiable { + let id: String + let title: String + let detail: String + let systemImage: String + let destination: AnyView + } + + private var tools: [ToolItem] { + [ + ToolItem(id: "console", title: "Console", detail: "Live device logs", systemImage: "terminal", destination: AnyView(ConsoleLogsView())), + ToolItem(id: "deviceinfo", title: "Device Info", detail: "View detailed device metadata", systemImage: "iphone.and.arrow.forward", destination: AnyView(DeviceInfoView())), + ToolItem(id: "profiles", title: "App Expiry", detail: "Check app expiration dates", systemImage: "calendar.badge.clock", destination: AnyView(ProfileView())), + ToolItem(id: "processes", title: "Processes", detail: "Inspect running apps", systemImage: "rectangle.stack.person.crop", destination: AnyView(ProcessInspectorView())), + ToolItem(id: "location", title: "Location Simulation", detail: "Simulate GPS location", systemImage: "location", destination: AnyView(LocationSimulationView())) + ] + } + + var body: some View { + NavigationStack { + List(tools) { tool in + NavigationLink { + tool.destination + } label: { + Label { + VStack(alignment: .leading, spacing: 2) { + Text(tool.title) + Text(tool.detail) + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: tool.systemImage) + } + } + } + .navigationTitle("Tools") + } + } +} diff --git a/StikJIT/en.lproj/Localizable.strings b/StikJIT/en.lproj/Localizable.strings deleted file mode 100644 index 1e168d69..00000000 --- a/StikJIT/en.lproj/Localizable.strings +++ /dev/null @@ -1,33 +0,0 @@ -"Welcome to StikDebug %@!" = "Welcome to StikDebug %@!"; -"Click connect to get started" = "Click connect to get started"; -"Pick pairing file to get started" = "Pick pairing file to get started"; -"Device Not Mounted" = "Device Not Mounted"; -"The Developer Disk Image has not been mounted yet. Check in settings for more information." = "The Developer Disk Image has not been mounted yet. Connect to Wi-Fi and force-restart StikDebug."; -"Connect by App" = "Connect by App"; -"Select Pairing File" = "Select Pairing File"; -"Connect by PID" = "Connect by PID"; -"Open Console" = "Open Console"; -"Processing pairing file..." = "Processing pairing file..."; -"\u2713 Pairing file successfully imported" = "\u2713 Pairing file successfully imported"; -"Please enter the PID of the process you want to connect to" = "Please enter the PID of the process you want to connect to"; -"Invalid PID" = "Invalid PID"; -"Success" = "Success"; -"JIT has been enabled for pid %d." = "JIT has been enabled for pid %d."; -"Installed Apps" = "Installed Apps"; -"No Debuggable App Found" = "No Debuggable App Found"; -"StikDebug can only connect to apps with the \"get-task-allow\" entitlement. Please check if the app you want to connect to is signed with a development certificate." = "StikDebug can only connect to apps with the \"get-task-allow\" entitlement. Please check if the app you want to connect to is signed with a development certificate."; -"Favorites (%d/4)" = "Favorites (%d/4)"; -"Recents" = "Recents"; -"All Applications" = "All Applications"; -"Remove Favorite" = "Remove Favorite"; -"Add to Favorites" = "Add to Favorites"; -"Copy Bundle ID" = "Copy Bundle ID"; -"Delete" = "Delete"; -"Loading..." = "Loading..."; -"Error Occurred While Executing the Default Script." = "Error Occurred While Executing the Default Script."; -"Unsupported OS Version" = "Unsupported OS Version"; -"StikJIT only supports 17.4 and above. Your device is running iOS/iPadOS %@" = "StikJIT only supports 17.4 and above. Your device is running iOS/iPadOS %@"; -"Cancel" = "Cancel"; -"OK" = "OK"; - -"Enable Advanced Options" = "Enable Advanced Options"; diff --git a/StikJIT/es.lproj/Localizable.strings b/StikJIT/es.lproj/Localizable.strings deleted file mode 100644 index be0829ab..00000000 --- a/StikJIT/es.lproj/Localizable.strings +++ /dev/null @@ -1,33 +0,0 @@ -"Welcome to StikDebug %@!" = "\u00a1Bienvenido a StikDebug %@!"; -"Click connect to get started" = "Haz clic en conectar para comenzar"; -"Pick pairing file to get started" = "Selecciona el archivo de emparejamiento para comenzar"; -"Device Not Mounted" = "Dispositivo no montado"; -"The Developer Disk Image has not been mounted yet. Check in settings for more information." = "La imagen del disco del desarrollador a\u00fan no se ha montado. Con\u00e9ctese a la red Wi‑Fi y fuerce el reinicio de StikDebug."; -"Connect by App" = "Conectar por app"; -"Select Pairing File" = "Seleccionar archivo de emparejamiento"; -"Connect by PID" = "Conectar por PID"; -"Open Console" = "Abrir consola"; -"Processing pairing file..." = "Procesando archivo de emparejamiento..."; -"\u2713 Pairing file successfully imported" = "\u2713 Archivo de emparejamiento importado correctamente"; -"Please enter the PID of the process you want to connect to" = "Introduce el PID del proceso al que deseas conectarte"; -"Invalid PID" = "PID no v\u00e1lido"; -"Success" = "\u00c9xito"; -"JIT has been enabled for pid %d." = "JIT habilitado para el pid %d."; -"Installed Apps" = "Aplicaciones instaladas"; -"No Debuggable App Found" = "No se encontr\u00f3 ninguna aplicaci\u00f3n depurable"; -"StikDebug can only connect to apps with the \"get-task-allow\" entitlement. Please check if the app you want to connect to is signed with a development certificate." = "StikDebug solo puede conectarse a aplicaciones con el derecho \"get-task-allow\". Verifica que la aplicaci\u00f3n a la que deseas conectarte est\u00e9 firmada con un certificado de desarrollo."; -"Favorites (%d/4)" = "Favoritos (%d/4)"; -"Recents" = "Recientes"; -"All Applications" = "Todas las aplicaciones"; -"Remove Favorite" = "Eliminar favorito"; -"Add to Favorites" = "A\u00f1adir a favoritos"; -"Copy Bundle ID" = "Copiar Bundle ID"; -"Delete" = "Eliminar"; -"Loading..." = "Cargando..."; -"Error Occurred While Executing the Default Script." = "Ocurri\u00f3 un error al ejecutar el script predeterminado."; -"Unsupported OS Version" = "Versi\u00f3n de iOS no compatible"; -"StikJIT only supports 17.4 and above. Your device is running iOS/iPadOS %@" = "StikJIT solo es compatible con la versi\u00f3n 17.4 o superior. Tu dispositivo ejecuta iOS/iPadOS %@"; -"Cancel" = "Cancelar"; -"OK" = "Aceptar"; - -"Enable Advanced Options" = "Habilitar opciones avanzadas"; diff --git a/StikJIT/idevice/JITEnableContext.m b/StikJIT/idevice/JITEnableContext.m index 4f71bfb8..06bc3905 100644 --- a/StikJIT/idevice/JITEnableContext.m +++ b/StikJIT/idevice/JITEnableContext.m @@ -70,7 +70,6 @@ - (LogFuncC)createCLogger:(LogFunc)logger { va_start(args, format); NSString* fmt = [NSString stringWithCString:format encoding:NSASCIIStringEncoding]; NSString* message = [[NSString alloc] initWithFormat:fmt arguments:args]; - NSLog(@"%@", message); if ([message containsString:@"ERROR"] || [message containsString:@"Error"]) { [[LogManagerBridge shared] addErrorLog:message]; @@ -92,16 +91,9 @@ - (LogFuncC)createCLogger:(LogFunc)logger { - (IdevicePairingFile*)getPairingFileWithError:(NSError**)error { NSFileManager* fm = [NSFileManager defaultManager]; NSURL* docPathUrl = [fm URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].firstObject; - NSString* currentDeviceUUIDStr = [NSUserDefaults.standardUserDefaults stringForKey:@"DeviceLibraryActiveDeviceID"]; - NSURL* pairingFileURL; - if(!currentDeviceUUIDStr || [currentDeviceUUIDStr isEqualToString:@"00000000-0000-0000-0000-000000000001"]) { - pairingFileURL = [docPathUrl URLByAppendingPathComponent:@"pairingFile.plist"]; - } else { - pairingFileURL = [docPathUrl URLByAppendingPathComponent:[NSString stringWithFormat:@"DeviceLibrary/Pairings/%@.mobiledevicepairing", currentDeviceUUIDStr]]; - } + NSURL* pairingFileURL = [docPathUrl URLByAppendingPathComponent:@"pairingFile.plist"]; if (![fm fileExistsAtPath:pairingFileURL.path]) { - NSLog(@"Pairing file not found!"); *error = [self errorWithStr:@"Pairing file not found!" code:-17]; return nil; } @@ -182,8 +174,6 @@ - (BOOL)startHeartbeat:(NSError**)err { intptr_t isTimeout = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, (uint64_t)(5 * NSEC_PER_SEC))); if(isTimeout) { Ccompletion(-1, "Heartbeat failed to complete in reasonable time."); - } else { - NSLog(@"Start heartbeat success %p", pthread_self()); } os_unfair_lock_lock(&heartbeatLock); @@ -203,34 +193,6 @@ - (BOOL)ensureHeartbeatWithError:(NSError**)err { return YES; } -- (BOOL)debugAppWithBundleID:(NSString*)bundleID logger:(LogFunc)logger jsCallback:(DebugAppCallback)jsCallback { - NSError* err = nil; - [self ensureHeartbeatWithError:&err]; - if(err) { - logger(err.localizedDescription); - return NO; - } - - return debug_app(provider, - [bundleID UTF8String], - [self createCLogger:logger], jsCallback) == 0; -} - -- (BOOL)debugAppWithPID:(int)pid logger:(LogFunc)logger jsCallback:(DebugAppCallback)jsCallback { - NSError* err = nil; - [self ensureHeartbeatWithError:&err]; - if(err) { - logger(err.localizedDescription); - return NO; - } - - return debug_app_pid(provider, - pid, - [self createCLogger:logger], jsCallback) == 0; -} - - - - (void)dealloc { diff --git a/StikJIT/idevice/applist.m b/StikJIT/idevice/applist.m index 768e440e..002d4eb4 100644 --- a/StikJIT/idevice/applist.m +++ b/StikJIT/idevice/applist.m @@ -256,16 +256,20 @@ static BOOL isHiddenSystemApp(plist_t app) UIImage* getAppIcon(IdeviceProviderHandle* provider, NSString* bundleID, NSString** error) { SpringBoardServicesClientHandle *client = NULL; - if (springboard_services_connect(provider, &client)) { - *error = @"Failed to connect to SpringBoard Services"; + IdeviceFfiError *err = springboard_services_connect(provider, &client); + if (err) { + *error = [NSString stringWithUTF8String:err->message ?: "Failed to connect to SpringBoard Services"]; + idevice_error_free(err); return nil; } void *pngData = NULL; size_t dataLen = 0; - if (springboard_services_get_icon(client, [bundleID UTF8String], &pngData, &dataLen)) { + err = springboard_services_get_icon(client, [bundleID UTF8String], &pngData, &dataLen); + if (err) { + *error = [NSString stringWithUTF8String:err->message ?: "Failed to get app icon"]; + idevice_error_free(err); springboard_services_free(client); - *error = @"Failed to get app icon"; return nil; } diff --git a/StikJIT/idevice/heartbeat.m b/StikJIT/idevice/heartbeat.m index 4bbaee31..63953876 100644 --- a/StikJIT/idevice/heartbeat.m +++ b/StikJIT/idevice/heartbeat.m @@ -20,96 +20,76 @@ void startHeartbeat(IdevicePairingFile* pairing_file, IdeviceProviderHandle** pr IdeviceProviderHandle* newProvider = *provider; IdeviceFfiError* err = nil; - // Create the socket address (replace with your device's IP) struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(LOCKDOWN_PORT); - - NSString* deviceIP = [[NSUserDefaults standardUserDefaults] stringForKey:@"TunnelDeviceIP"]; - inet_pton(AF_INET, deviceIP ? [deviceIP UTF8String] : "10.7.0.1", &addr.sin_addr); - - + + NSString* deviceIP = [[NSUserDefaults standardUserDefaults] stringForKey:@"customTargetIP"]; + inet_pton(AF_INET, (deviceIP && deviceIP.length > 0) ? [deviceIP UTF8String] : "10.7.0.1", &addr.sin_addr); + err = idevice_tcp_provider_new((struct sockaddr *)&addr, pairing_file, "ExampleProvider", &newProvider); if (err != NULL) { - fprintf(stderr, "Failed to create TCP provider: [%d] %s", err->code, - err->message); completion(err->code, err->message); idevice_pairing_file_free(pairing_file); idevice_error_free(err); - return; } - - // Connect to installation proxy + HeartbeatClientHandle *client = NULL; err = heartbeat_connect(newProvider, &client); if (err != NULL) { - fprintf(stderr, "Failed to connect to installation proxy: [%d] %s", - err->code, err->message); completion(err->code, err->message); idevice_provider_free(newProvider); idevice_error_free(err); - return; } - - *provider = newProvider; - + *provider = newProvider; bool completionCalled = false; - u_int64_t current_interval = 15; + while (1) { - // Get the new interval u_int64_t new_interval = 0; err = heartbeat_get_marco(client, current_interval, &new_interval); if (err != NULL) { - fprintf(stderr, "Failed to get marco: [%d] %s token = %d, pthread_self = %p\n", err->code, err->message, heartbeatToken, pthread_self()); - if(!completionCalled) { + if (!completionCalled) { completion(err->code, err->message); } heartbeat_client_free(client); idevice_error_free(err); return; } - - // if a new heartbeat thread is running we quit current one + + // If a newer heartbeat thread has started, yield to it if (heartbeatToken != globalHeartbeatToken) { heartbeat_client_free(client); - - NSLog(@"Quitting %d, now token = %d", heartbeatToken, globalHeartbeatToken); return; } - + current_interval = new_interval + 5; - - // Reply + err = heartbeat_send_polo(client); if (err != NULL) { - fprintf(stderr, "Failed to get marco: [%d] %s", err->code, err->message); - if(!completionCalled) { + if (!completionCalled) { completion(err->code, err->message); } heartbeat_client_free(client); idevice_error_free(err); - return; } - + if (lastHeartbeatDate && [[NSDate now] timeIntervalSinceDate:lastHeartbeatDate] > current_interval) { lastHeartbeatDate = nil; -// NSLog(@"[SJ] Heartbeat marco receive timeout, probably disconnected, token = %d, pthread_self = %p", heartbeatToken, pthread_self()); return; } lastHeartbeatDate = [NSDate now]; -// NSLog(@"[SJ] Heartbeat finished at %@, token = %d, pthread_self = %p", lastHeartbeatDate, heartbeatToken, pthread_self()); - if (!completionCalled) { completion(0, "Heartbeat succeeded"); + completionCalled = true; } } } diff --git a/StikJIT/idevice/ideviceinfo.m b/StikJIT/idevice/ideviceinfo.m index abcd8026..d1e7aeab 100644 --- a/StikJIT/idevice/ideviceinfo.m +++ b/StikJIT/idevice/ideviceinfo.m @@ -15,13 +15,13 @@ NSError* makeError(int code, NSString* msg); LockdowndClientHandle* ideviceinfo_c_init(IdeviceProviderHandle* g_provider, IdevicePairingFile* g_sess_pf, NSError** error) { - struct LockdowndClientHandle * g_client = NULL; - struct IdeviceFfiError * err = lockdownd_connect(g_provider, &g_client); + LockdowndClientHandle *g_client = NULL; + IdeviceFfiError *err = lockdownd_connect(g_provider, &g_client); if (err) { *error = makeError(err->code, @(err->message)); idevice_pairing_file_free(g_sess_pf); idevice_error_free(err); - return 0; + return NULL; } err = lockdownd_start_session(g_client, g_sess_pf); @@ -30,8 +30,7 @@ *error = makeError(err->code, @(err->message)); idevice_error_free(err); lockdownd_client_free(g_client); - g_client = NULL; - return 0; + return NULL; } return g_client; @@ -64,22 +63,15 @@ @implementation JITEnableContext(DeviceInfo) - (LockdowndClientHandle*)ideviceInfoInit:(NSError**)error { [self ensureHeartbeatWithError:error]; - if(*error) { - return 0; - } - IdevicePairingFile* pf = [self getPairingFileWithError:error]; - if(*error) { - return 0; - } - + if (*error) { return nil; } + IdevicePairingFile *pf = [self getPairingFileWithError:error]; + if (*error) { return nil; } return ideviceinfo_c_init(provider, pf, error); } - (char*)ideviceInfoGetXMLWithLockdownClient:(LockdowndClientHandle*)lockdownClient error:(NSError**)error { [self ensureHeartbeatWithError:error]; - if(*error) { - return 0; - } + if (*error) { return NULL; } return ideviceinfo_c_get_xml(lockdownClient, error); } @end diff --git a/StikJIT/idevice/jit.m b/StikJIT/idevice/jit.m index dff4c174..aa357ee3 100644 --- a/StikJIT/idevice/jit.m +++ b/StikJIT/idevice/jit.m @@ -20,12 +20,65 @@ #import "JITEnableContext.h" #import "JITEnableContextInternal.h" +// MARK: - Shared debug session + +typedef struct { + AdapterHandle *adapter; + RsdHandshakeHandle *handshake; + RemoteServerHandle *remote_server; + DebugProxyHandle *debug_proxy; +} DebugSession; + +static void debug_session_free(DebugSession *s) { + if (s->debug_proxy) { debug_proxy_free(s->debug_proxy); s->debug_proxy = NULL; } + if (s->remote_server) { remote_server_free(s->remote_server); s->remote_server = NULL; } + if (s->handshake) { rsd_handshake_free(s->handshake); s->handshake = NULL; } + if (s->adapter) { adapter_free(s->adapter); s->adapter = NULL; } +} + +// Connects to the device, performs the RSD handshake, and sets up the debug proxy. +// Returns 0 on success; cleans up any partial state and returns 1 on failure. +static int connect_debug_session(IdeviceProviderHandle *tcp_provider, DebugSession *out) { + memset(out, 0, sizeof(*out)); + IdeviceFfiError *err = NULL; + + CoreDeviceProxyHandle *core_device = NULL; + err = core_device_proxy_connect(tcp_provider, &core_device); + if (err) { idevice_error_free(err); return 1; } + + uint16_t rsd_port = 0; + err = core_device_proxy_get_server_rsd_port(core_device, &rsd_port); + if (err) { idevice_error_free(err); core_device_proxy_free(core_device); return 1; } + + err = core_device_proxy_create_tcp_adapter(core_device, &out->adapter); + if (err) { idevice_error_free(err); core_device_proxy_free(core_device); return 1; } + core_device = NULL; // ownership transferred to adapter + + AdapterStreamHandle *stream = NULL; + err = adapter_connect(out->adapter, rsd_port, (ReadWriteOpaque **)&stream); + if (err) { idevice_error_free(err); debug_session_free(out); return 1; } + + err = rsd_handshake_new((ReadWriteOpaque *)stream, &out->handshake); + if (err) { idevice_error_free(err); adapter_close(stream); debug_session_free(out); return 1; } + stream = NULL; // consumed by handshake + + err = remote_server_connect_rsd(out->adapter, out->handshake, &out->remote_server); + if (err) { idevice_error_free(err); debug_session_free(out); return 1; } + + err = debug_proxy_connect_rsd(out->adapter, out->handshake, &out->debug_proxy); + if (err) { idevice_error_free(err); debug_session_free(out); return 1; } + + return 0; +} + +// MARK: - Debug server commands + void runDebugServerCommand(int pid, DebugProxyHandle* debug_proxy, RemoteServerHandle* remote_server, LogFuncC logger, DebugAppCallback callback) { - // enable QStartNoAckMode + // Enable QStartNoAckMode char *disableResponse = NULL; debug_proxy_send_ack(debug_proxy); debug_proxy_send_ack(debug_proxy); @@ -35,35 +88,33 @@ void runDebugServerCommand(int pid, logger("QStartNoAckMode result = %s, err = %d", disableResponse, err); idevice_string_free(disableResponse); debug_proxy_set_ack_mode(debug_proxy, false); - - if(callback) { + + if (callback) { dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); callback(pid, debug_proxy, remote_server, semaphore); dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); err = debug_proxy_send_raw(debug_proxy, "\x03", 1); usleep(500); } else { - // Send vAttach command with PID in hex char attach_command[64]; snprintf(attach_command, sizeof(attach_command), "vAttach;%" PRIx64, pid); - + DebugserverCommandHandle *attach_cmd = debugserver_command_new(attach_command, NULL, 0); if (attach_cmd == NULL) { logger("Failed to create attach command"); return; } - + char *attach_response = NULL; err = debug_proxy_send_command(debug_proxy, attach_cmd, &attach_response); debugserver_command_free(attach_cmd); - if (err) { - logger("Failed to attach to process: %d", err); + logger("Failed to attach to process: %d", err->code); + idevice_error_free(err); } else if (attach_response != NULL) { logger("Attach response: %s", attach_response); idevice_string_free(attach_response); } - } // Send detach command @@ -74,7 +125,6 @@ void runDebugServerCommand(int pid, char *detach_response = NULL; err = debug_proxy_send_command(debug_proxy, detach_cmd, &detach_response); debugserver_command_free(detach_cmd); - if (err) { logger("Failed to detach from process: %d", err->code); idevice_error_free(err); @@ -85,238 +135,49 @@ void runDebugServerCommand(int pid, } } -int debug_app(IdeviceProviderHandle* tcp_provider, const char *bundle_id, LogFuncC logger, DebugAppCallback callback) { - // Initialize logger -// idevice_init_logger(Info, Disabled, NULL); - IdeviceFfiError* err = 0; - - CoreDeviceProxyHandle *core_device = NULL; - err = core_device_proxy_connect(tcp_provider, &core_device); - if (err != NULL) { - fprintf(stderr, "Failed to connect to CoreDeviceProxy: [%d] %s\n", - err->code, err->message); - idevice_error_free(err); - return 1; - } +// MARK: - Public entry points - uint16_t rsd_port; - err = core_device_proxy_get_server_rsd_port(core_device, &rsd_port); - if (err != NULL) { - fprintf(stderr, "Failed to get server RSD port: [%d] %s\n", err->code, - err->message); - idevice_error_free(err); - core_device_proxy_free(core_device); - return 1; - } - printf("Server RSD Port: %d\n", rsd_port); - - printf("\n=== Creating TCP Tunnel Adapter ===\n"); - - AdapterHandle *adapter = NULL; - err = core_device_proxy_create_tcp_adapter(core_device, &adapter); - if (err != NULL) { - fprintf(stderr, "Failed to create TCP adapter: [%d] %s\n", err->code, - err->message); - idevice_error_free(err); - core_device_proxy_free(core_device); - return 1; - } - core_device = NULL; // adapter takes ownership of the proxy +int debug_app(IdeviceProviderHandle* tcp_provider, const char *bundle_id, LogFuncC logger, DebugAppCallback callback) { + DebugSession session; + if (connect_debug_session(tcp_provider, &session) != 0) return 1; - AdapterStreamHandle *stream = NULL; - err = adapter_connect(adapter, rsd_port, (ReadWriteOpaque **)&stream); - if (err != NULL) { - fprintf(stderr, "Failed to connect to RSD port: [%d] %s\n", err->code, - err->message); - idevice_error_free(err); - adapter_free(adapter); - return 1; + ProcessControlHandle *process_control = NULL; + IdeviceFfiError *err = process_control_new(session.remote_server, &process_control); + if (err) { + idevice_error_free(err); + debug_session_free(&session); + return 1; } - printf("Successfully connected to RSD port\n"); - printf("\n=== Performing RSD Handshake ===\n"); - - RsdHandshakeHandle *handshake = NULL; - err = rsd_handshake_new((ReadWriteOpaque *)stream, &handshake); - if (err != NULL) { - fprintf(stderr, "Failed to perform RSD handshake: [%d] %s\n", err->code, - err->message); - idevice_error_free(err); - adapter_close(stream); - adapter_free(adapter); - return 1; - } - stream = NULL; - - // Create RemoteServerClient - RemoteServerHandle *remote_server = NULL; - err = remote_server_connect_rsd(adapter, handshake, &remote_server); - if (err != NULL) { - fprintf(stderr, "Failed to create remote server: [%d] %s", err->code, - err->message); - idevice_error_free(err); - adapter_free(adapter); - rsd_handshake_free(handshake); - return 1; + uint64_t pid = 0; + err = process_control_launch_app(process_control, bundle_id, NULL, 0, NULL, 0, true, false, &pid); + if (err) { + idevice_error_free(err); + process_control_free(process_control); + debug_session_free(&session); + return 1; } - printf("\n=== Testing Process Control ===\n"); - - // Create ProcessControlClient - ProcessControlHandle *process_control = NULL; - err = process_control_new(remote_server, &process_control); - if (err != NULL) { - fprintf(stderr, "Failed to create process control client: [%d] %s", - err->code, err->message); - idevice_error_free(err); - remote_server_free(remote_server); - return 1; - } + runDebugServerCommand((int)pid, session.debug_proxy, session.remote_server, logger, callback); - // Launch application - uint64_t pid; - err = process_control_launch_app(process_control, bundle_id, NULL, 0, NULL, 0, - true, false, &pid); - if (err != NULL) { - fprintf(stderr, "Failed to launch app: [%d] %s", err->code, err->message); - idevice_error_free(err); - process_control_free(process_control); - remote_server_free(remote_server); - return 1; - } - printf("Successfully launched app with PID: %llu\n", pid); - - printf("\n=== Setting up Debug Proxy ===\n"); - - DebugProxyHandle *debug_proxy = NULL; - err = debug_proxy_connect_rsd(adapter, handshake, &debug_proxy); - if (err != NULL) { - fprintf(stderr, "Failed to create debug proxy client: [%d] %s\n", err->code, - err->message); - idevice_error_free(err); - rsd_handshake_free(handshake); - adapter_free(adapter); - return 1; - } - - runDebugServerCommand((int)pid, debug_proxy, remote_server, logger, callback); - - /***************************************************************** - * Cleanup - *****************************************************************/ - debug_proxy_free(debug_proxy); process_control_free(process_control); - remote_server_free(remote_server); - rsd_handshake_free(handshake); - adapter_free(adapter); - + debug_session_free(&session); logger("Debug session completed"); return 0; } - int debug_app_pid(IdeviceProviderHandle* tcp_provider, int pid, LogFuncC logger, DebugAppCallback callback) { - IdeviceFfiError* err = 0; - - CoreDeviceProxyHandle *core_device = NULL; - err = core_device_proxy_connect(tcp_provider, &core_device); - if (err != NULL) { - fprintf(stderr, "Failed to connect to CoreDeviceProxy: [%d] %s\n", - err->code, err->message); - idevice_error_free(err); - return 1; - } - - uint16_t rsd_port; - err = core_device_proxy_get_server_rsd_port(core_device, &rsd_port); - if (err != NULL) { - fprintf(stderr, "Failed to get server RSD port: [%d] %s\n", err->code, - err->message); - idevice_error_free(err); - core_device_proxy_free(core_device); - return 1; - } - printf("Server RSD Port: %d\n", rsd_port); - - printf("\n=== Creating TCP Tunnel Adapter ===\n"); - - AdapterHandle *adapter = NULL; - err = core_device_proxy_create_tcp_adapter(core_device, &adapter); - if (err != NULL) { - fprintf(stderr, "Failed to create TCP adapter: [%d] %s\n", err->code, - err->message); - idevice_error_free(err); - core_device_proxy_free(core_device); - return 1; - } - core_device = NULL; - - AdapterStreamHandle *stream = NULL; - err = adapter_connect(adapter, rsd_port, (ReadWriteOpaque **)&stream); - if (err != NULL) { - fprintf(stderr, "Failed to connect to RSD port: [%d] %s\n", err->code, - err->message); - idevice_error_free(err); - adapter_free(adapter); - return 1; - } - printf("Successfully connected to RSD port\n"); + DebugSession session; + if (connect_debug_session(tcp_provider, &session) != 0) return 1; - printf("\n=== Performing RSD Handshake ===\n"); + runDebugServerCommand(pid, session.debug_proxy, session.remote_server, logger, callback); - RsdHandshakeHandle *handshake = NULL; - err = rsd_handshake_new((ReadWriteOpaque *)stream, &handshake); - if (err != NULL) { - fprintf(stderr, "Failed to perform RSD handshake: [%d] %s\n", err->code, - err->message); - idevice_error_free(err); - adapter_close(stream); - adapter_free(adapter); - return 1; - } - stream = NULL; - - // Create RemoteServerClient - RemoteServerHandle *remote_server = NULL; - err = remote_server_connect_rsd(adapter, handshake, &remote_server); - if (err != NULL) { - fprintf(stderr, "Failed to create remote server: [%d] %s", err->code, - err->message); - idevice_error_free(err); - adapter_free(adapter); - rsd_handshake_free(handshake); - return 1; - } - - printf("\n=== Setting up Debug Proxy ===\n"); - - DebugProxyHandle *debug_proxy = NULL; - err = debug_proxy_connect_rsd(adapter, handshake, &debug_proxy); - if (err != NULL) { - fprintf(stderr, "Failed to create debug proxy client: [%d] %s\n", err->code, - err->message); - idevice_error_free(err); - rsd_handshake_free(handshake); - adapter_free(adapter); - return 1; - } - - - runDebugServerCommand(pid, debug_proxy, remote_server, logger, callback); - - /***************************************************************** - * Cleanup - *****************************************************************/ - debug_proxy_free(debug_proxy); - rsd_handshake_free(handshake); - adapter_free(adapter); - + debug_session_free(&session); logger("Debug session completed"); return 0; } int launch_app_via_proxy(IdeviceProviderHandle* tcp_provider, const char *bundle_id, LogFuncC logger) { -// idevice_init_logger(Info, Disabled, NULL); IdeviceFfiError* err = NULL; CoreDeviceProxyHandle *core_device = NULL; @@ -329,101 +190,46 @@ int launch_app_via_proxy(IdeviceProviderHandle* tcp_provider, const char *bundle int result = 1; err = core_device_proxy_connect(tcp_provider, &core_device); - if (err != NULL) { - fprintf(stderr, "Failed to connect to CoreDeviceProxy: [%d] %s\n", err->code, err->message); - idevice_error_free(err); - goto cleanup; - } + if (err) { idevice_error_free(err); goto cleanup; } uint16_t rsd_port = 0; err = core_device_proxy_get_server_rsd_port(core_device, &rsd_port); - if (err != NULL) { - fprintf(stderr, "Failed to get server RSD port: [%d] %s\n", err->code, err->message); - idevice_error_free(err); - goto cleanup; - } + if (err) { idevice_error_free(err); goto cleanup; } err = core_device_proxy_create_tcp_adapter(core_device, &adapter); - if (err != NULL) { - fprintf(stderr, "Failed to create TCP adapter: [%d] %s\n", err->code, err->message); - idevice_error_free(err); - goto cleanup; - } + if (err) { idevice_error_free(err); goto cleanup; } core_device = NULL; // ownership transferred to adapter err = adapter_connect(adapter, rsd_port, (ReadWriteOpaque **)&stream); - if (err != NULL) { - fprintf(stderr, "Failed to connect to RSD port: [%d] %s\n", err->code, err->message); - idevice_error_free(err); - goto cleanup; - } + if (err) { idevice_error_free(err); goto cleanup; } err = rsd_handshake_new((ReadWriteOpaque *)stream, &handshake); - if (err != NULL) { - fprintf(stderr, "Failed to perform RSD handshake: [%d] %s\n", err->code, err->message); - idevice_error_free(err); - goto cleanup; - } + if (err) { idevice_error_free(err); goto cleanup; } stream = NULL; // consumed by handshake/adapter stack err = remote_server_connect_rsd(adapter, handshake, &remote_server); - if (err != NULL) { - fprintf(stderr, "Failed to create remote server: [%d] %s\n", err->code, err->message); - idevice_error_free(err); - goto cleanup; - } + if (err) { idevice_error_free(err); goto cleanup; } err = process_control_new(remote_server, &process_control); - if (err != NULL) { - fprintf(stderr, "Failed to create process control client: [%d] %s\n", err->code, err->message); - idevice_error_free(err); - goto cleanup; - } + if (err) { idevice_error_free(err); goto cleanup; } - err = process_control_launch_app(process_control, - bundle_id, - NULL, - 0, - NULL, - 0, - false, - true, - &pid); - if (err != NULL) { - fprintf(stderr, "Failed to launch app: [%d] %s\n", err->code, err->message); + err = process_control_launch_app(process_control, bundle_id, NULL, 0, NULL, 0, false, true, &pid); + if (err) { idevice_error_free(err); - if (logger) { - logger("Failed to launch app: %s", bundle_id); - } + if (logger) logger("Failed to launch app: %s", bundle_id); goto cleanup; } - if (logger) { - logger("Launched app (PID %llu)", pid); - } - + if (logger) logger("Launched app (PID %llu)", pid); result = 0; cleanup: - if (process_control) { - process_control_free(process_control); - } - if (remote_server) { - remote_server_free(remote_server); - } - if (handshake) { - rsd_handshake_free(handshake); - } - if (stream) { - adapter_close(stream); - } - if (adapter) { - adapter_free(adapter); - } - if (core_device) { - core_device_proxy_free(core_device); - } - + if (process_control) process_control_free(process_control); + if (remote_server) remote_server_free(remote_server); + if (handshake) rsd_handshake_free(handshake); + if (stream) adapter_close(stream); + if (adapter) adapter_free(adapter); + if (core_device) core_device_proxy_free(core_device); return result; } @@ -433,42 +239,31 @@ @implementation JITEnableContext(JIT) - (BOOL)debugAppWithBundleID:(NSString*)bundleID logger:(LogFunc)logger jsCallback:(DebugAppCallback)jsCallback { NSError* err = nil; [self ensureHeartbeatWithError:&err]; - if(err) { + if (err) { logger(err.localizedDescription); return NO; } - - return debug_app(provider, - [bundleID UTF8String], - [self createCLogger:logger], jsCallback) == 0; + return debug_app(provider, [bundleID UTF8String], [self createCLogger:logger], jsCallback) == 0; } - (BOOL)debugAppWithPID:(int)pid logger:(LogFunc)logger jsCallback:(DebugAppCallback)jsCallback { NSError* err = nil; [self ensureHeartbeatWithError:&err]; - if(err) { + if (err) { logger(err.localizedDescription); return NO; } - - return debug_app_pid(provider, - pid, - [self createCLogger:logger], jsCallback) == 0; + return debug_app_pid(provider, pid, [self createCLogger:logger], jsCallback) == 0; } - (BOOL)launchAppWithoutDebug:(NSString*)bundleID logger:(LogFunc)logger { NSError* err = nil; [self ensureHeartbeatWithError:&err]; - if(err) { + if (err) { logger(err.localizedDescription); return NO; } - - int result = launch_app_via_proxy(provider, - [bundleID UTF8String], - [self createCLogger:logger]); - return result == 0; + return launch_app_via_proxy(provider, [bundleID UTF8String], [self createCLogger:logger]) == 0; } - @end diff --git a/StikJIT/idevice/ls.c b/StikJIT/idevice/location_simulation.c similarity index 86% rename from StikJIT/idevice/ls.c rename to StikJIT/idevice/location_simulation.c index d3fc06cb..3e3e87e7 100644 --- a/StikJIT/idevice/ls.c +++ b/StikJIT/idevice/location_simulation.c @@ -1,11 +1,11 @@ // -// ls.c +// location_simulation.c // StikDebug // // Created by Stephen on 8/3/25. // -#include "ls.h" +#include "location_simulation.h" #include "idevice.h" #include #include @@ -36,7 +36,6 @@ int simulate_location(const char *device_ip, double longitude, const char *pairing_file) { - idevice_init_logger(Debug, Disabled, NULL); IdeviceFfiError *err = NULL; if (g_location_sim) { @@ -98,17 +97,21 @@ int simulate_location(const char *device_ip, cleanup_on_error(); return IPA_ERR_ADAPTER_CREATE; } + // core_device_proxy_create_tcp_adapter takes ownership of g_core_device + // (Rust moves it into the adapter). Null the pointer so cleanup_on_error + // does not attempt a second free. + g_core_device = NULL; AdapterStreamHandle *stream = NULL; - if ((err = adapter_connect(g_adapter, rsd_port, &stream))) { + if ((err = adapter_connect(g_adapter, rsd_port, (ReadWriteOpaque **)&stream))) { idevice_error_free(err); cleanup_on_error(); return IPA_ERR_STREAM; } - if ((err = rsd_handshake_new(stream, &g_handshake))) { + if ((err = rsd_handshake_new((ReadWriteOpaque *)stream, &g_handshake))) { idevice_error_free(err); - adapter_close(stream); + adapter_stream_close(stream); cleanup_on_error(); return IPA_ERR_HANDSHAKE; } @@ -121,6 +124,9 @@ int simulate_location(const char *device_ip, cleanup_on_error(); return IPA_ERR_REMOTE_SERVER; } + // remote_server_connect_rsd takes ownership of g_adapter and g_handshake. + g_adapter = NULL; + g_handshake = NULL; if ((err = location_simulation_new(g_remote_server, &g_location_sim))) { @@ -128,6 +134,8 @@ int simulate_location(const char *device_ip, cleanup_on_error(); return IPA_ERR_LOCATION_SIM; } + // location_simulation_new takes ownership of g_remote_server. + g_remote_server = NULL; if ((err = location_simulation_set(g_location_sim, latitude, diff --git a/StikJIT/idevice/ls.h b/StikJIT/idevice/location_simulation.h similarity index 91% rename from StikJIT/idevice/ls.h rename to StikJIT/idevice/location_simulation.h index 6e771d98..f1ff8d3e 100644 --- a/StikJIT/idevice/ls.h +++ b/StikJIT/idevice/location_simulation.h @@ -1,12 +1,12 @@ // -// ls.h +// location_simulation.h // StikDebug // // Created by Stephen on 8/3/25. // -#ifndef LS_H -#define LS_H +#ifndef LOCATION_SIMULATION_H +#define LOCATION_SIMULATION_H #include @@ -52,4 +52,4 @@ int clear_simulated_location(void); } #endif -#endif /* LS_H */ +#endif /* LOCATION_SIMULATION_H */ diff --git a/StikJIT/idevice/mount.m b/StikJIT/idevice/mount.m index fff1890a..4366b7be 100644 --- a/StikJIT/idevice/mount.m +++ b/StikJIT/idevice/mount.m @@ -10,79 +10,83 @@ @import Foundation; NSError* makeError(int code, NSString* msg); + size_t getMountedDeviceCount(IdeviceProviderHandle* provider, NSError** error) { - ImageMounterHandle* client = 0; - IdeviceFfiError* err = image_mounter_connect(provider, &client); + ImageMounterHandle *client = NULL; + IdeviceFfiError *err = image_mounter_connect(provider, &client); if (err) { *error = makeError(err->code, @(err->message)); idevice_error_free(err); return 0; } - plist_t* devices; + + plist_t *devices = NULL; size_t deviceLength = 0; err = image_mounter_copy_devices(client, &devices, &deviceLength); + image_mounter_free(client); if (err) { *error = makeError(err->code, @(err->message)); idevice_error_free(err); return 0; } - // no need to read the device, we just check the length - for(int i = 0;i < deviceLength; ++i) { + + for (int i = 0; i < (int)deviceLength; i++) { plist_free(devices[i]); } - idevice_data_free((uint8_t *)devices, deviceLength*sizeof(plist_t)); - image_mounter_free(client); + idevice_data_free((uint8_t *)devices, deviceLength * sizeof(plist_t)); return deviceLength; } - int mountPersonalDDI(IdeviceProviderHandle* provider, IdevicePairingFile* pairingFile2, NSString* imagePath, NSString* trustcachePath, NSString* manifestPath, NSError** error) { - NSData* image = [NSData dataWithContentsOfFile:imagePath]; - NSData* trustcache = [NSData dataWithContentsOfFile:trustcachePath]; - NSData* buildManifest = [NSData dataWithContentsOfFile:manifestPath]; - if(!image || !trustcache || !buildManifest) { + NSData *image = [NSData dataWithContentsOfFile:imagePath]; + NSData *trustcache = [NSData dataWithContentsOfFile:trustcachePath]; + NSData *buildManifest = [NSData dataWithContentsOfFile:manifestPath]; + if (!image || !trustcache || !buildManifest) { idevice_pairing_file_free(pairingFile2); *error = makeError(1, @"Failed to read one or more files"); return 1; } - - LockdowndClientHandle* lockdownClient = 0; - IdeviceFfiError* err = lockdownd_connect(provider, &lockdownClient); + + LockdowndClientHandle *lockdownClient = NULL; + IdeviceFfiError *err = lockdownd_connect(provider, &lockdownClient); if (err) { *error = makeError(6, @(err->message)); idevice_pairing_file_free(pairingFile2); idevice_error_free(err); return 6; } - + err = lockdownd_start_session(lockdownClient, pairingFile2); idevice_pairing_file_free(pairingFile2); if (err) { *error = makeError(7, @(err->message)); idevice_error_free(err); - return 7; // EC: 7 + lockdownd_client_free(lockdownClient); + return 7; } - - plist_t uniqueChipIDPlist = 0; - err = lockdownd_get_value(lockdownClient, "UniqueChipID", 0, &uniqueChipIDPlist); + + plist_t uniqueChipIDPlist = NULL; + err = lockdownd_get_value(lockdownClient, "UniqueChipID", NULL, &uniqueChipIDPlist); + lockdownd_client_free(lockdownClient); if (err) { *error = makeError(8, @(err->message)); idevice_error_free(err); - return 8; // EC: 8 + return 8; } - + uint64_t uniqueChipID = 0; plist_get_uint_val(uniqueChipIDPlist, &uniqueChipID); - - ImageMounterHandle* mounterClient = 0; + plist_free(uniqueChipIDPlist); + + ImageMounterHandle *mounterClient = NULL; err = image_mounter_connect(provider, &mounterClient); if (err) { *error = makeError(9, @(err->message)); idevice_error_free(err); - return 9; // EC: 9 + return 9; } - - image_mounter_mount_personalized( + + err = image_mounter_mount_personalized( mounterClient, provider, [image bytes], @@ -91,36 +95,34 @@ int mountPersonalDDI(IdeviceProviderHandle* provider, IdevicePairingFile* pairin [trustcache length], [buildManifest bytes], [buildManifest length], - nil, + NULL, uniqueChipID - ); - + ); + image_mounter_free(mounterClient); + if (err) { *error = makeError(10, @(err->message)); idevice_error_free(err); - return 10; // EC: 10 + return 10; } - + return 0; } @implementation JITEnableContext(DDI) + - (NSUInteger)getMountedDeviceCount:(NSError**)error { [self ensureHeartbeatWithError:error]; - if(*error) { - return NO; - } + if (*error) { return 0; } return getMountedDeviceCount(provider, error); } + - (NSInteger)mountPersonalDDIWithImagePath:(NSString*)imagePath trustcachePath:(NSString*)trustcachePath manifestPath:(NSString*)manifestPath error:(NSError**)error { [self ensureHeartbeatWithError:error]; - if(*error) { - return 0; - } - IdevicePairingFile* pairing = [self getPairingFileWithError:error]; - if(*error) { - return 0; - } + if (*error) { return 0; } + IdevicePairingFile *pairing = [self getPairingFileWithError:error]; + if (*error) { return 0; } return mountPersonalDDI(provider, pairing, imagePath, trustcachePath, manifestPath, error); } + @end diff --git a/StikJIT/idevice/process.m b/StikJIT/idevice/process.m index 89ce6d7a..d1397810 100644 --- a/StikJIT/idevice/process.m +++ b/StikJIT/idevice/process.m @@ -9,107 +9,115 @@ #import "JITEnableContextInternal.h" @import Foundation; -@implementation JITEnableContext(Process) +// MARK: - Shared AppService session + +typedef struct { + AdapterHandle *adapter; + RsdHandshakeHandle *handshake; + AppServiceHandle *appService; +} AppServiceSession; + +static void app_service_session_free(AppServiceSession *s) { + if (s->appService) { app_service_free(s->appService); s->appService = NULL; } + if (s->handshake) { rsd_handshake_free(s->handshake); s->handshake = NULL; } + if (s->adapter) { adapter_free(s->adapter); s->adapter = NULL; } +} + +// Connects to the device via CoreDeviceProxy → Adapter → RSD → AppService. +// Returns 0 on success; cleans up any partial state and returns 1 on failure. +static int connect_app_service(IdeviceProviderHandle *provider, + AppServiceSession *out, + JITEnableContext *ctx, + NSError **outError) +{ + memset(out, 0, sizeof(*out)); + IdeviceFfiError *ffiError = NULL; -- (NSArray*)fetchProcessesViaAppServiceWithError:(NSError **)error { - [self ensureHeartbeatWithError:error]; - if(*error) { - return nil; - } - - IdeviceProviderHandle *providerToUse = provider; CoreDeviceProxyHandle *coreProxy = NULL; - AdapterHandle *adapter = NULL; + ffiError = core_device_proxy_connect(provider, &coreProxy); + if (ffiError) { + *outError = [ctx errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to connect CoreDeviceProxy"] + code:ffiError->code]; + idevice_error_free(ffiError); + return 1; + } + + uint16_t rsdPort = 0; + ffiError = core_device_proxy_get_server_rsd_port(coreProxy, &rsdPort); + if (ffiError) { + *outError = [ctx errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Unable to resolve RSD port"] + code:ffiError->code]; + idevice_error_free(ffiError); + core_device_proxy_free(coreProxy); + return 1; + } + + ffiError = core_device_proxy_create_tcp_adapter(coreProxy, &out->adapter); + if (ffiError) { + *outError = [ctx errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to create adapter"] + code:ffiError->code]; + idevice_error_free(ffiError); + core_device_proxy_free(coreProxy); + return 1; + } + coreProxy = NULL; // ownership transferred to adapter + AdapterStreamHandle *stream = NULL; - RsdHandshakeHandle *handshake = NULL; - AppServiceHandle *appService = NULL; - ProcessTokenC *processes = NULL; - uintptr_t count = 0; - NSMutableArray *result = nil; - IdeviceFfiError *ffiError = NULL; + ffiError = adapter_connect(out->adapter, rsdPort, (ReadWriteOpaque **)&stream); + if (ffiError) { + *outError = [ctx errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Adapter connect failed"] + code:ffiError->code]; + idevice_error_free(ffiError); + app_service_session_free(out); + return 1; + } + + ffiError = rsd_handshake_new((ReadWriteOpaque *)stream, &out->handshake); + if (ffiError) { + *outError = [ctx errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "RSD handshake failed"] + code:ffiError->code]; + idevice_error_free(ffiError); + adapter_stream_close(stream); + app_service_session_free(out); + return 1; + } + stream = NULL; // consumed by handshake - do { + ffiError = app_service_connect_rsd(out->adapter, out->handshake, &out->appService); + if (ffiError) { + *outError = [ctx errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Unable to open AppService"] + code:ffiError->code]; + idevice_error_free(ffiError); + app_service_session_free(out); + return 1; + } - ffiError = core_device_proxy_connect(providerToUse, &coreProxy); - if (ffiError) { - if (error) { - *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to connect CoreDeviceProxy"] - code:ffiError->code]; - } - idevice_error_free(ffiError); - ffiError = NULL; - break; - } + return 0; +} - uint16_t rsdPort = 0; - ffiError = core_device_proxy_get_server_rsd_port(coreProxy, &rsdPort); - if (ffiError) { - if (error) { - *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Unable to resolve RSD port"] - code:ffiError->code]; - } - idevice_error_free(ffiError); - ffiError = NULL; - break; - } +// MARK: - JITEnableContext(Process) - ffiError = core_device_proxy_create_tcp_adapter(coreProxy, &adapter); - if (ffiError) { - if (error) { - *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to create adapter"] - code:ffiError->code]; - } - idevice_error_free(ffiError); - ffiError = NULL; - break; - } +@implementation JITEnableContext(Process) - coreProxy = NULL; - ffiError = adapter_connect(adapter, rsdPort, (ReadWriteOpaque **)&stream); - if (ffiError) { - if (error) { - *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Adapter connect failed"] - code:ffiError->code]; - } - idevice_error_free(ffiError); - ffiError = NULL; - break; - } +- (NSArray*)fetchProcessesViaAppServiceWithError:(NSError **)error { + [self ensureHeartbeatWithError:error]; + if (*error) { return nil; } - ffiError = rsd_handshake_new((ReadWriteOpaque *)stream, &handshake); - if (ffiError) { - if (error) { - *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "RSD handshake failed"] - code:ffiError->code]; - } - idevice_error_free(ffiError); - ffiError = NULL; - break; - } + AppServiceSession session; + if (connect_app_service(provider, &session, self, error) != 0) { return nil; } - stream = NULL; - ffiError = app_service_connect_rsd(adapter, handshake, &appService); - if (ffiError) { - if (error) { - *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Unable to open AppService"] - code:ffiError->code]; - } - idevice_error_free(ffiError); - ffiError = NULL; - break; - } + ProcessTokenC *processes = NULL; + uintptr_t count = 0; + IdeviceFfiError *ffiError = app_service_list_processes(session.appService, &processes, &count); - ffiError = app_service_list_processes(appService, &processes, &count); - if (ffiError) { - if (error) { - *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to list processes"] - code:ffiError->code]; - } - idevice_error_free(ffiError); - ffiError = NULL; - break; + NSMutableArray *result = nil; + if (ffiError) { + if (error) { + *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to list processes"] + code:ffiError->code]; } - + idevice_error_free(ffiError); + } else { result = [NSMutableArray arrayWithCapacity:count]; for (uintptr_t idx = 0; idx < count; idx++) { ProcessTokenC proc = processes[idx]; @@ -120,34 +128,18 @@ @implementation JITEnableContext(Process) } [result addObject:entry]; } - } while (0); - - if (processes && count > 0) { - app_service_free_process_list(processes, count); - } - if (appService) { - app_service_free(appService); - } - if (handshake) { - rsd_handshake_free(handshake); - } - if (stream) { - adapter_stream_close(stream); - } - if (adapter) { - adapter_free(adapter); - } - if (coreProxy) { - core_device_proxy_free(coreProxy); + if (processes && count > 0) { + app_service_free_process_list(processes, count); + } } + + app_service_session_free(&session); return result; } - (NSArray*)_fetchProcessListLocked:(NSError**)error { [self ensureHeartbeatWithError:error]; - if(*error) { - return nil; - } + if (*error) { return nil; } return [self fetchProcessesViaAppServiceWithError:error]; } @@ -165,123 +157,28 @@ @implementation JITEnableContext(Process) - (BOOL)killProcessWithPID:(int)pid error:(NSError **)error { [self ensureHeartbeatWithError:error]; - if(*error) { - return nil; - } - - IdeviceProviderHandle *providerToUse = provider; - CoreDeviceProxyHandle *coreProxy = NULL; - AdapterHandle *adapter = NULL; - AdapterStreamHandle *stream = NULL; - RsdHandshakeHandle *handshake = NULL; - AppServiceHandle *appService = NULL; - SignalResponseC *signalResponse = NULL; - IdeviceFfiError *ffiError = NULL; - BOOL success = NO; - - do { - ffiError = core_device_proxy_connect(providerToUse, &coreProxy); - if (ffiError) { - if (error) { - *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to connect CoreDeviceProxy"] - code:ffiError->code]; - } - idevice_error_free(ffiError); - ffiError = NULL; - break; - } + if (*error) { return NO; } - uint16_t rsdPort = 0; - ffiError = core_device_proxy_get_server_rsd_port(coreProxy, &rsdPort); - if (ffiError) { - if (error) { - *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Unable to resolve RSD port"] - code:ffiError->code]; - } - idevice_error_free(ffiError); - ffiError = NULL; - break; - } - - ffiError = core_device_proxy_create_tcp_adapter(coreProxy, &adapter); - if (ffiError) { - if (error) { - *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to create adapter"] - code:ffiError->code]; - } - idevice_error_free(ffiError); - ffiError = NULL; - break; - } + AppServiceSession session; + if (connect_app_service(provider, &session, self, error) != 0) { return NO; } - coreProxy = NULL; - ffiError = adapter_connect(adapter, rsdPort, (ReadWriteOpaque **)&stream); - if (ffiError) { - if (error) { - *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Adapter connect failed"] - code:ffiError->code]; - } - idevice_error_free(ffiError); - ffiError = NULL; - break; - } - - ffiError = rsd_handshake_new((ReadWriteOpaque *)stream, &handshake); - if (ffiError) { - if (error) { - *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "RSD handshake failed"] - code:ffiError->code]; - } - idevice_error_free(ffiError); - ffiError = NULL; - break; - } - - stream = NULL; - ffiError = app_service_connect_rsd(adapter, handshake, &appService); - if (ffiError) { - if (error) { - *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Unable to open AppService"] - code:ffiError->code]; - } - idevice_error_free(ffiError); - ffiError = NULL; - break; - } + SignalResponseC *signalResponse = NULL; + IdeviceFfiError *ffiError = app_service_send_signal(session.appService, (uint32_t)pid, SIGKILL, &signalResponse); - ffiError = app_service_send_signal(appService, (uint32_t)pid, SIGKILL, &signalResponse); - if (ffiError) { - if (error) { - *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to kill process"] - code:ffiError->code]; - } - idevice_error_free(ffiError); - ffiError = NULL; - break; + BOOL success = NO; + if (ffiError) { + if (error) { + *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to kill process"] + code:ffiError->code]; } + idevice_error_free(ffiError); + } else { success = YES; - } while (0); - - if (signalResponse) { - app_service_free_signal_response(signalResponse); - } - if (appService) { - app_service_free(appService); - } - if (handshake) { - rsd_handshake_free(handshake); - } - if (stream) { - adapter_stream_close(stream); - } - if (adapter) { - adapter_free(adapter); - } - if (coreProxy) { - core_device_proxy_free(coreProxy); } + + if (signalResponse) { app_service_free_signal_response(signalResponse); } + app_service_session_free(&session); return success; } - @end diff --git a/StikJIT/idevice/profiles.m b/StikJIT/idevice/profiles.m index 81503aed..107f99fb 100644 --- a/StikJIT/idevice/profiles.m +++ b/StikJIT/idevice/profiles.m @@ -15,17 +15,17 @@ NSArray* fetchAppProfiles(IdeviceProviderHandle* provider, NSError** error) { - MisagentClientHandle* misagentHandle = 0; - IdeviceFfiError * err = misagent_connect(provider, &misagentHandle); + MisagentClientHandle *misagentHandle = NULL; + IdeviceFfiError *err = misagent_connect(provider, &misagentHandle); if (err) { *error = makeError(err->code, @(err->message)); idevice_error_free(err); return nil; } - - uint8_t** profileArr = 0; + + uint8_t **profileArr = NULL; size_t profileCount = 0; - size_t* profileLengthArr = 0; + size_t *profileLengthArr = NULL; err = misagent_copy_all(misagentHandle, &profileArr, &profileLengthArr, &profileCount); if (err) { @@ -51,7 +51,7 @@ } bool removeProfile(IdeviceProviderHandle* provider, NSString* uuid, NSError** error) { - MisagentClientHandle* misagentHandle = 0; + MisagentClientHandle *misagentHandle = NULL; IdeviceFfiError * err = misagent_connect(provider, &misagentHandle); if (err) { *error = makeError(err->code, @(err->message)); @@ -72,7 +72,7 @@ bool removeProfile(IdeviceProviderHandle* provider, NSString* uuid, NSError** er } bool addProfile(IdeviceProviderHandle* provider, NSData* profile, NSError** error) { - MisagentClientHandle* misagentHandle = 0; + MisagentClientHandle *misagentHandle = NULL; IdeviceFfiError * err = misagent_connect(provider, &misagentHandle); if (err) { *error = makeError(err->code, @(err->message)); @@ -107,34 +107,25 @@ + (NSData*)decodeCMSData:(NSData *)cmsData return nil; } - NSData *xmlStart = [@"" dataUsingEncoding:NSASCIIStringEncoding]; + NSData *xmlStart = [@"" dataUsingEncoding:NSASCIIStringEncoding]; NSData *binaryMagic = [@"bplist00" dataUsingEncoding:NSASCIIStringEncoding]; - if (xmlStart && plistEnd) { - NSRange searchRange = NSMakeRange(0, cmsData.length); - NSRange startRange = [cmsData rangeOfData:xmlStart options:0 range:searchRange]; - if (startRange.location != NSNotFound) { - NSUInteger remainingLength = cmsData.length - startRange.location; - NSRange endSearchRange = NSMakeRange(startRange.location, remainingLength); - NSRange endRange = [cmsData rangeOfData:plistEnd options:0 range:endSearchRange]; - if (endRange.location != NSNotFound) { - NSUInteger plistStart = startRange.location; - NSUInteger plistEndIndex = NSMaxRange(endRange); - if (plistEndIndex > plistStart && plistEndIndex <= cmsData.length) { - NSRange plistRange = NSMakeRange(plistStart, plistEndIndex - plistStart); - return [cmsData subdataWithRange:plistRange]; - } + NSRange startRange = [cmsData rangeOfData:xmlStart options:0 range:NSMakeRange(0, cmsData.length)]; + if (startRange.location != NSNotFound) { + NSRange endSearchRange = NSMakeRange(startRange.location, cmsData.length - startRange.location); + NSRange endRange = [cmsData rangeOfData:plistEnd options:0 range:endSearchRange]; + if (endRange.location != NSNotFound) { + NSUInteger plistEndIndex = NSMaxRange(endRange); + if (plistEndIndex > startRange.location && plistEndIndex <= cmsData.length) { + return [cmsData subdataWithRange:NSMakeRange(startRange.location, plistEndIndex - startRange.location)]; } } } - if (binaryMagic) { - NSRange binaryRange = [cmsData rangeOfData:binaryMagic options:0 range:NSMakeRange(0, cmsData.length)]; - if (binaryRange.location != NSNotFound) { - NSRange plistRange = NSMakeRange(binaryRange.location, cmsData.length - binaryRange.location); - return [cmsData subdataWithRange:plistRange]; - } + NSRange binaryRange = [cmsData rangeOfData:binaryMagic options:0 range:NSMakeRange(0, cmsData.length)]; + if (binaryRange.location != NSNotFound) { + return [cmsData subdataWithRange:NSMakeRange(binaryRange.location, cmsData.length - binaryRange.location)]; } if (error) { @@ -161,7 +152,7 @@ @implementation JITEnableContext(Profile) - (BOOL)removeProfileWithUUID:(NSString*)uuid error:(NSError **)error { [self ensureHeartbeatWithError:error]; if(*error) { - return nil; + return NO; } return removeProfile(provider, uuid, error); @@ -170,7 +161,7 @@ - (BOOL)removeProfileWithUUID:(NSString*)uuid error:(NSError **)error { - (BOOL)addProfile:(NSData*)profile error:(NSError **)error { [self ensureHeartbeatWithError:error]; if(*error) { - return nil; + return NO; } return addProfile(provider, profile, error); } diff --git a/StikJIT/idevice/something.c b/StikJIT/idevice/something.c deleted file mode 100644 index b71382f9..00000000 --- a/StikJIT/idevice/something.c +++ /dev/null @@ -1,166 +0,0 @@ -// -// something.c -// StikDebug -// -// Created by Stephen on 7/29/25. -// - -#include "something.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -static int mmap_file(const char *path, uint8_t **data, size_t *len) -{ - int fd = open(path, O_RDONLY); - if (fd < 0) { - perror("open"); - return 0; - } - struct stat st; - if (fstat(fd, &st) != 0) { - perror("fstat"); - close(fd); - return 0; - } - *len = (size_t)st.st_size; - *data = mmap(NULL, *len, PROT_READ, MAP_PRIVATE, fd, 0); - close(fd); - if (*data == MAP_FAILED) { - perror("mmap"); - return 0; - } - return 1; -} - -static void munmap_file(uint8_t *data, size_t len) -{ - if (data && data != MAP_FAILED) { - munmap(data, len); - } -} - -int install_ipa(const char *ip, - const char *pairing_file_path, - const char *udid, - const char *ipa_path) -{ - (void)udid; - idevice_init_logger(Debug, Disabled, NULL); - - struct sockaddr_in addr = {0}; - addr.sin_family = AF_INET; - addr.sin_port = htons(LOCKDOWN_PORT); - if (inet_pton(AF_INET, ip, &addr.sin_addr) != 1) { - fprintf(stderr, "Invalid IP address: %s\n", ip); - return IPA_INSTALLER_ERR_INVALID_IP; - } - - IdevicePairingFile *pairing_file = NULL; - IdeviceFfiError *err = idevice_pairing_file_read(pairing_file_path, - &pairing_file); - if (err) { - fprintf(stderr, "Pairing file read failed: [%d] %s\n", - err->code, err->message); - idevice_error_free(err); - return IPA_INSTALLER_ERR_PAIRING_READ; - } - - IdeviceProviderHandle *provider = NULL; - err = idevice_tcp_provider_new((struct sockaddr *)&addr, - pairing_file, - "IPAInstaller", - &provider); - if (err) { - fprintf(stderr, "Provider create failed: [%d] %s\n", - err->code, err->message); - idevice_pairing_file_free(pairing_file); - idevice_error_free(err); - return IPA_INSTALLER_ERR_PROVIDER_CREATE; - } - - AfcClientHandle *afc = NULL; - err = afc_client_connect(provider, &afc); - if (err) { - fprintf(stderr, "AFC connect failed: [%d] %s\n", - err->code, err->message); - idevice_provider_free(provider); - idevice_pairing_file_free(pairing_file); - idevice_error_free(err); - return IPA_INSTALLER_ERR_AFC_CONNECT; - } - - uint8_t *ipa_data = NULL; - size_t ipa_len = 0; - if (!mmap_file(ipa_path, &ipa_data, &ipa_len)) { - fprintf(stderr, "Unable to read IPA file\n"); - afc_client_free(afc); - idevice_provider_free(provider); - idevice_pairing_file_free(pairing_file); - return IPA_INSTALLER_ERR_IPA_READ; - } - - const char *slash = strrchr(ipa_path, '/'); - const char *fname = slash ? slash + 1 : ipa_path; - char dest[256]; - snprintf(dest, sizeof(dest), "/PublicStaging/%s", fname); - - AfcFileHandle *remote = NULL; - err = afc_file_open(afc, dest, AfcWrOnly, &remote); - if (err) { - fprintf(stderr, "AFC open failed: [%d] %s\n", - err->code, err->message); - munmap_file(ipa_data, ipa_len); - afc_client_free(afc); - idevice_provider_free(provider); - idevice_pairing_file_free(pairing_file); - idevice_error_free(err); - return IPA_INSTALLER_ERR_AFC_OPEN; - } - - err = afc_file_write(remote, ipa_data, ipa_len); - afc_file_close(remote); - munmap_file(ipa_data, ipa_len); - if (err) { - fprintf(stderr, "AFC write failed: [%d] %s\n", - err->code, err->message); - afc_client_free(afc); - idevice_provider_free(provider); - idevice_pairing_file_free(pairing_file); - idevice_error_free(err); - return IPA_INSTALLER_ERR_AFC_WRITE; - } - - InstallationProxyClientHandle *ipc = NULL; - err = installation_proxy_connect(provider, &ipc); - if (err) { - fprintf(stderr, "installation_proxy connect failed: [%d] %s\n", - err->code, err->message); - afc_client_free(afc); - idevice_provider_free(provider); - idevice_pairing_file_free(pairing_file); - idevice_error_free(err); - return IPA_INSTALLER_ERR_INSTALLPROXY; - } - - err = installation_proxy_install(ipc, dest, NULL); - installation_proxy_client_free(ipc); - afc_client_free(afc); - idevice_provider_free(provider); - - if (err) { - fprintf(stderr, "IPA install failed: [%d] %s\n", - err->code, err->message); - idevice_error_free(err); - return IPA_INSTALLER_ERR_INSTALL; - } - - fprintf(stderr, "IPA installed successfully\n"); - return IPA_INSTALLER_OK; -} diff --git a/StikJIT/idevice/something.h b/StikJIT/idevice/something.h deleted file mode 100644 index ccb1a8c5..00000000 --- a/StikJIT/idevice/something.h +++ /dev/null @@ -1,32 +0,0 @@ -// -// something.h -// StikDebug -// -// Created by Stephen on 7/29/25. -// - -#ifndef SOMETHING_H -#define SOMETHING_H - -#include "idevice.h" -#include - -typedef enum { - IPA_INSTALLER_OK = 0, /* success */ - IPA_INSTALLER_ERR_PAIRING_READ = 1, /* could not read pairing file */ - IPA_INSTALLER_ERR_PROVIDER_CREATE = 2, /* idevice_tcp_provider_new fail */ - IPA_INSTALLER_ERR_AFC_CONNECT = 3, /* afc_client_connect fail */ - IPA_INSTALLER_ERR_IPA_READ = 4, /* failed to mmap or read IPA */ - IPA_INSTALLER_ERR_AFC_OPEN = 5, /* afc_file_open fail */ - IPA_INSTALLER_ERR_AFC_WRITE = 6, /* afc_file_write fail */ - IPA_INSTALLER_ERR_INSTALLPROXY = 7, /* installation_proxy connect */ - IPA_INSTALLER_ERR_INSTALL = 8, /* installation_proxy_install */ - IPA_INSTALLER_ERR_INVALID_IP = 9, /* inet_pton failed */ -} ipa_installer_error_t; - -int install_ipa(const char *ip, - const char *pairing_file_path, - const char *udid, - const char *ipa_path); - -#endif /* SOMETHING_H */ diff --git a/StikJIT/it.lproj/Localizable.strings b/StikJIT/it.lproj/Localizable.strings deleted file mode 100644 index d430acfc..00000000 --- a/StikJIT/it.lproj/Localizable.strings +++ /dev/null @@ -1,33 +0,0 @@ -"Welcome to StikDebug %@!" = "Benvenuto su StikDebug %@!"; -"Click connect to get started" = "Clicca su Connetti per cominciare"; -"Pick pairing file to get started" = "Seleziona un file di pairing per iniziare"; -"Device Not Mounted" = "Dispositivo non montato"; -"The Developer Disk Image has not been mounted yet. Check in settings for more information." = "L'Immagine del disco dello sviluppatore non è stata ancora montata. Connettiti al Wi-Fi e riavvia forzatamente StikDebug."; -"Connect by App" = "Connetti dall'App"; -"Select Pairing File" = "Seleziona file di Pairing"; -"Connect by PID" = "Connetti con PID"; -"Open Console" = "Apri Console"; -"Processing pairing file..." = "Elaborazione del file di pairing..."; -"\u2713 Pairing file successfully imported" = "\u2713 File di Pairing importato correttamente"; -"Please enter the PID of the process you want to connect to" = "Per favore inserisci il PID del processo a cui vuoi connetterti"; -"Invalid PID" = "PID Invalido"; -"Success" = "Successo"; -"JIT has been enabled for pid %d." = "JIT è stato abilitato per il pid %d."; -"Installed Apps" = "App Installate"; -"No Debuggable App Found" = "Nessuna App Debuggabile trovata"; -"StikDebug can only connect to apps with the \"get-task-allow\" entitlement. Please check if the app you want to connect to is signed with a development certificate." = "StikDebug può connettersi solamente ad app con l'entitlement \"get-task-allow\". Per favore verifica che l'app a cui vuoi connetterti è firmata con un certificato di sviluppo."; -"Favorites (%d/4)" = "Preferiti (%d/4)"; -"Recents" = "Recenti"; -"All Applications" = "Tutte le applicazioni"; -"Remove Favorite" = "Rimuovi preferito"; -"Add to Favorites" = "Aggiungi ai preferiti"; -"Copy Bundle ID" = "Copia il Bundle ID"; -"Delete" = "Elimina"; -"Loading..." = "Caricamento..."; -"Error Occurred While Executing the Default Script." = "Errore durante l'esecuzione dello Script di Default."; -"Unsupported OS Version" = "Versione non supportata"; -"StikJIT only supports 17.4 and above. Your device is running iOS/iPadOS %@" = "StikJIT supporta solamente 17.4 e successivi. Il tuo dispositivo ha iOS/iPadOS %@"; -"Cancel" = "Cancella"; -"OK" = "OK"; - -"Enable Advanced Options" = "Abilita Opzioni Avanzate";