Add more accessibility features

This commit is contained in:
Stephen 2026-05-12 12:00:57 -04:00
parent 0169ec97fe
commit 3b1e779aa6
3 changed files with 159 additions and 26 deletions

View file

@ -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"

View file

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

View file

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