From 3b1e779aa65020ec8d8abd72651b3ec62e4f3753 Mon Sep 17 00:00:00 2001 From: Stephen <158498287+StephenDev0@users.noreply.github.com> Date: Tue, 12 May 2026 12:00:57 -0400 Subject: [PATCH] Add more accessibility features --- StikJIT/Utilities/Extensions.swift | 8 ++ StikJIT/Views/HomeView.swift | 87 +++++++++++++++++++--- StikJIT/Views/InstalledAppsListView.swift | 90 ++++++++++++++++++----- 3 files changed, 159 insertions(+), 26 deletions(-) diff --git a/StikJIT/Utilities/Extensions.swift b/StikJIT/Utilities/Extensions.swift index bae3ed4..05f14fc 100644 --- a/StikJIT/Utilities/Extensions.swift +++ b/StikJIT/Utilities/Extensions.swift @@ -8,6 +8,14 @@ import Foundation import UniformTypeIdentifiers import UIKit +enum AccessibilityAnnouncer { + static func announce(_ message: String) { + DispatchQueue.main.async { + UIAccessibility.post(notification: .announcement, argument: message) + } + } +} + enum PairingFileStore { static let fileName = "rp_pairing_file.plist" private static let legacyFileName = "pairingFile.plist" diff --git a/StikJIT/Views/HomeView.swift b/StikJIT/Views/HomeView.swift index d21ec51..0250f1a 100644 --- a/StikJIT/Views/HomeView.swift +++ b/StikJIT/Views/HomeView.swift @@ -80,6 +80,7 @@ struct HomeView: View { @State private var viewDidAppeared = false @State private var pendingJITEnableConfiguration : JITEnableConfiguration? = nil @State private var isShowingPairingFilePicker = false + @State private var debugFeedback: DebugFeedback? @State var scriptViewShow = false @State private var isShowingConsole = false @@ -89,12 +90,26 @@ struct HomeView: View { let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + private struct DebugFeedback: Identifiable { + let id = UUID() + let message: String + let isError: Bool + let isWorking: Bool + } + var body: some View { - InstalledAppsListView(onSelectApp: { selectedBundle in + InstalledAppsListView(onSelectApp: { selectedBundle, selectedName in bundleID = selectedBundle HapticFeedbackHelper.trigger() - startJITInBackground(bundleID: selectedBundle) + startJITInBackground(bundleID: selectedBundle, displayName: selectedName) }, showDoneButton: false, onImportPairingFile: { isShowingPairingFilePicker = true }) + .overlay(alignment: .bottom) { + if let debugFeedback { + debugFeedbackView(debugFeedback) + .padding(.bottom, 40) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } .onAppear { startTunnelInBackground() MountingProgress.shared.checkforMounted() @@ -227,6 +242,28 @@ struct HomeView: View { } } } + + private func debugFeedbackView(_ feedback: DebugFeedback) -> some View { + HStack(spacing: 10) { + if feedback.isWorking { + ProgressView() + .controlSize(.small) + } + + Text(feedback.message) + .font(.subheadline.weight(.semibold)) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Capsule().fill(.ultraThinMaterial)) + .foregroundStyle(feedback.isError ? .red : .primary) + .shadow(radius: 4) + .padding(.horizontal, 20) + .accessibilityElement(children: .ignore) + .accessibilityLabel(feedback.message) + } + private func getJsCallback(_ script: Data, name: String? = nil) -> DebugAppCallback { return { pid, debugProxyHandle, remoteServerHandle, semaphore in let model = RunJSViewModel(pid: Int(pid), @@ -250,9 +287,15 @@ struct HomeView: View { } } - private func startJITInBackground(bundleID: String? = nil, pid: Int? = nil, scriptData: Data? = nil, scriptName: String? = nil, triggeredByURLScheme: Bool = false) { + private func startJITInBackground(bundleID: String? = nil, pid: Int? = nil, scriptData: Data? = nil, scriptName: String? = nil, triggeredByURLScheme: Bool = false, displayName: String? = nil) { isProcessing = true + let targetName = displayName ?? bundleID ?? pid.map { String(format: "process %d".localized, $0) } ?? "app".localized + let startingMessage = String(format: "Starting JIT for %@".localized, targetName) LogManager.shared.addInfoLog("Starting Debug for \(bundleID ?? String(pid ?? 0))") + withAnimation { + debugFeedback = DebugFeedback(message: startingMessage, isError: false, isWorking: true) + } + AccessibilityAnnouncer.announce(startingMessage) if triggeredByURLScheme { pubTunnelConnected = false @@ -266,9 +309,31 @@ struct HomeView: View { if triggeredByURLScheme { sleep(1) } - let finishProcessing = { + + let finishProcessing: (Bool, String?) -> Void = { success, detail in DispatchQueue.main.async { isProcessing = false + let message = success + ? String(format: "JIT request completed for %@".localized, targetName) + : String(format: "JIT failed for %@".localized, targetName) + let feedback = DebugFeedback(message: message, isError: !success, isWorking: false) + withAnimation { + debugFeedback = feedback + } + AccessibilityAnnouncer.announce(message) + + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + if debugFeedback?.id == feedback.id { + withAnimation { + debugFeedback = nil + } + } + } + + if !success { + let failureMessage = detail ?? "StikDebug could not launch or attach to the selected app. Check that the VPN is enabled, the pairing file is current, and the app is still installed.".localized + showAlert(title: "Failed to Enable JIT".localized, message: failureMessage, showOk: true) + } } } @@ -286,16 +351,20 @@ struct HomeView: View { callback = getJsCallback(sd, name: scriptName ?? bundleID ?? "Script") } - let logger: LogFunc = { message in if let message { LogManager.shared.addInfoLog(message) } } + var lastDebugMessage: String? + let logger: LogFunc = { message in + if let message { + lastDebugMessage = 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 { - showAlert(title: "Failed to Debug App".localized, message: "Either bundle ID or PID should be specified.".localized, showOk: true) - } + lastDebugMessage = "Either bundle ID or PID should be specified.".localized success = false } @@ -308,7 +377,7 @@ struct HomeView: View { } } } - finishProcessing() + finishProcessing(success, success ? nil : lastDebugMessage) } } diff --git a/StikJIT/Views/InstalledAppsListView.swift b/StikJIT/Views/InstalledAppsListView.swift index 9b03b31..5d7edde 100644 --- a/StikJIT/Views/InstalledAppsListView.swift +++ b/StikJIT/Views/InstalledAppsListView.swift @@ -39,7 +39,7 @@ struct InstalledAppsListView: View { @AppStorage("pinnedSystemAppNames") private var pinnedSystemAppNames: [String: String] = [:] @Environment(\.dismiss) private var dismiss - var onSelectApp: (String) -> Void + var onSelectApp: (String, String) -> Void var showDoneButton: Bool = true var onImportPairingFile: (() -> Void)? = nil @@ -372,12 +372,13 @@ private enum AppListTab: Int, CaseIterable, Identifiable { bundleID: bundleID, appName: appName, isLaunching: launchingBundles.contains(bundleID), performanceMode: performanceMode - ) { startLaunching(bundleID: bundleID) } + ) { startLaunching(bundleID: bundleID, appName: appName) } .overlay(alignment: .topTrailing) { if isPinned { Image(systemName: "star.fill") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.yellow).padding(6) + .accessibilityHidden(true) } } .contextMenu { @@ -448,20 +449,24 @@ private enum AppListTab: Int, CaseIterable, Identifiable { if touched { WidgetCenter.shared.reloadAllTimelines() } } - private func startLaunching(bundleID: String) { + private func startLaunching(bundleID: String, appName: String) { guard !launchingBundles.contains(bundleID) else { return } launchingBundles.insert(bundleID) Haptics.selection() + AccessibilityAnnouncer.announce(String(format: "Launching %@".localized, appName)) viewModel.launchWithoutDebug(bundleID: bundleID) { success in launchingBundles.remove(bundleID) - let message = success ? "Launch request sent".localized : "Launch failed".localized + let message = success + ? String(format: "Launch request sent for %@".localized, appName) + : String(format: "Launch failed for %@".localized, appName) let feedback = LaunchFeedback(message: message, success: success) if success { Haptics.light() } + AccessibilityAnnouncer.announce(message) withAnimation { launchFeedback = feedback @@ -521,7 +526,7 @@ struct AppButton: View { @AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true @AppStorage("enableAdvancedOptions") private var enableAdvancedOptions = false - var onSelectApp: (String) -> Void + var onSelectApp: (String, String) -> Void let sharedDefaults: UserDefaults let performanceMode: Bool @@ -534,7 +539,7 @@ struct AppButton: View { appName: String, recentApps: Binding<[String]>, favoriteApps: Binding<[String]>, - onSelectApp: @escaping (String) -> Void, + onSelectApp: @escaping (String, String) -> Void, sharedDefaults: UserDefaults, performanceMode: Bool ) { @@ -577,6 +582,7 @@ struct AppButton: View { } } .padding(.vertical, loadAppIconsOnJIT ? 4 : 8) + .contentShape(Rectangle()) } .buttonStyle(.plain) .contextMenu { @@ -588,8 +594,7 @@ struct AppButton: View { .disabled(!favoriteApps.contains(bundleID) && favoriteApps.count >= 4) } Button { - UIPasteboard.general.string = bundleID - Haptics.light() + copyBundleID() } label: { Label("Copy Bundle ID", systemImage: "doc.on.doc") } @@ -615,8 +620,7 @@ struct AppButton: View { .tint(.yellow) Button { - UIPasteboard.general.string = bundleID - Haptics.light() + copyBundleID() } label: { Label("Copy ID", systemImage: "doc.on.doc") } @@ -638,8 +642,34 @@ struct AppButton: View { } } .accessibilityElement(children: .ignore) - .accessibilityLabel("\(appName)") - .accessibilityHint("Double-tap to select. Swipe for actions, long-press for options.") + .accessibilityLabel(String(format: "Enable JIT for %@".localized, appName)) + .accessibilityValue(accessibilityValue) + .accessibilityHint("Double-tap to open the app and enable JIT. Use the actions rotor for favorites or bundle ID.".localized) + .accessibilityAddTraits(.isButton) + .accessibilityRemoveTraits(.isStaticText) + .accessibilityAction(named: Text(favoriteAccessibilityActionLabel)) { + toggleFavorite() + } + .accessibilityAction(named: Text("Copy Bundle ID".localized)) { + copyBundleID() + } + } + + private var accessibilityValue: String { + var parts = [String(format: "Bundle ID %@".localized, bundleID)] + if favoriteApps.contains(bundleID) { + parts.append("Favorite".localized) + } + if let assignedScriptName { + parts.append(String(format: "Assigned script %@".localized, assignedScriptName)) + } + return parts.joined(separator: ", ") + } + + private var favoriteAccessibilityActionLabel: String { + favoriteApps.contains(bundleID) + ? "Remove from Favorites".localized + : "Add to Favorites".localized } // MARK: Icon @@ -678,18 +708,29 @@ struct AppButton: View { recentApps = Array(recentApps.prefix(3)) } persistIfChanged() - onSelectApp(bundleID) + onSelectApp(bundleID, appName) } private func toggleFavorite() { Haptics.light() - if favoriteApps.contains(bundleID) { + let wasFavorite = favoriteApps.contains(bundleID) + if wasFavorite { favoriteApps.removeAll { $0 == bundleID } } else if favoriteApps.count < 4 { favoriteApps.insert(bundleID, at: 0) recentApps.removeAll { $0 == bundleID } + } else { + AccessibilityAnnouncer.announce("Favorites are full".localized) + return } persistIfChanged() + AccessibilityAnnouncer.announce(wasFavorite ? "Removed from Favorites".localized : "Added to Favorites".localized) + } + + private func copyBundleID() { + UIPasteboard.general.string = bundleID + Haptics.light() + AccessibilityAnnouncer.announce("Bundle ID copied".localized) } private func assignScript(_ url: URL?) { @@ -792,6 +833,7 @@ struct LaunchAppRow: View { } } .padding(.vertical, loadAppIconsOnJIT ? 4 : 8) + .contentShape(Rectangle()) } .buttonStyle(.plain) .disabled(isLaunching) @@ -806,8 +848,22 @@ struct LaunchAppRow: View { } } .accessibilityElement(children: .ignore) - .accessibilityLabel("\(appName)") - .accessibilityHint("Double-tap to launch via CoreDevice.") + .accessibilityLabel(String(format: "Launch %@".localized, appName)) + .accessibilityValue(accessibilityValue) + .accessibilityHint(isLaunching + ? "Launch request in progress.".localized + : "Double-tap to launch this app without enabling JIT.".localized) + .accessibilityAddTraits(.isButton) + .accessibilityRemoveTraits(.isStaticText) + .accessibilityAction(named: Text("Launch App".localized)) { + guard !isLaunching else { return } + launchAction() + } + } + + private var accessibilityValue: String { + let state = isLaunching ? "Launching".localized : "Ready".localized + return "\(state), \(String(format: "Bundle ID %@".localized, bundleID))" } private var iconView: some View { @@ -1112,7 +1168,7 @@ extension Dictionary: @retroactive RawRepresentable where Key: Codable, Value: C struct InstalledAppsListView_Previews: PreviewProvider { static var previews: some View { - InstalledAppsListView { _ in } + InstalledAppsListView { _, _ in } .environment(\.colorScheme, .dark) } }