- Store source files as named CustomInstrumentFile rows per def - Render sidebar with disclosure groups - Drive editor via Monaco project models with activePath - Validate Add/Rename/Delete dialogs and submit on Enter - Fork installed hook packs into editable custom instruments - Reload instances on def change; clear stale compat reasons live - Focus editor on selection (native + Monaco)
471 lines
18 KiB
Swift
471 lines
18 KiB
Swift
import Frida
|
|
import LumaCore
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
struct AddInstrumentSheet: View {
|
|
let session: LumaCore.ProcessSession
|
|
let engine: Engine
|
|
@Binding var selection: SidebarItemID?
|
|
let onInstrumentAdded: ((LumaCore.InstrumentInstance) -> Void)?
|
|
let onBrowseCodeShare: () -> Void
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@State private var commitCoordinator = InstrumentConfigCommitCoordinator()
|
|
@State private var systemParams: SystemParameters?
|
|
|
|
@State private var selectedDescriptorID: InstrumentDescriptor.ID?
|
|
@State private var initialConfigJSON = Data()
|
|
@State private var compactPath: [InstrumentDescriptor.ID] = []
|
|
@State private var isShowingImportPicker = false
|
|
@State private var importErrorMessage: String?
|
|
@State private var alreadyAddedDescriptorIDs: Set<InstrumentDescriptor.ID> = []
|
|
|
|
#if canImport(UIKit)
|
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
private var isCompactWidth: Bool { horizontalSizeClass == .compact }
|
|
#else
|
|
private var isCompactWidth: Bool { false }
|
|
#endif
|
|
|
|
var descriptors: [InstrumentDescriptor] {
|
|
engine.descriptors
|
|
}
|
|
|
|
private var builtinDescriptors: [InstrumentDescriptor] {
|
|
descriptors.filter { $0.kind != .custom }
|
|
}
|
|
|
|
private var customDescriptors: [InstrumentDescriptor] {
|
|
descriptors.filter { $0.kind == .custom }
|
|
}
|
|
|
|
private static let newCustomDescriptorID: String = "custom:__new__"
|
|
private static let importHookPackDescriptorID: String = "custom:__import__"
|
|
|
|
private var selectedDescriptor: InstrumentDescriptor? {
|
|
guard let id = selectedDescriptorID else { return nil }
|
|
return descriptors.first { $0.id == id }
|
|
}
|
|
|
|
private var confirmActionLabel: String {
|
|
selectedDescriptorID == Self.importHookPackDescriptorID ? "Choose Folder\u{2026}" : "Add"
|
|
}
|
|
|
|
private var isConfirmActionDisabled: Bool {
|
|
if selectedDescriptorID == Self.newCustomDescriptorID { return false }
|
|
if selectedDescriptorID == Self.importHookPackDescriptorID { return false }
|
|
guard let descriptor = selectedDescriptor else { return true }
|
|
if isAlreadyAdded(descriptor) { return true }
|
|
return incompatibilityReason(for: descriptor) != nil
|
|
}
|
|
|
|
private func isAlreadyAdded(_ descriptor: InstrumentDescriptor) -> Bool {
|
|
alreadyAddedDescriptorIDs.contains(descriptor.id)
|
|
}
|
|
|
|
private func incompatibilityReason(for descriptor: InstrumentDescriptor) -> String? {
|
|
guard let systemParams else { return nil }
|
|
return descriptor.compatibility.incompatibilityReason(for: systemParams)
|
|
}
|
|
|
|
private func refreshAlreadyAdded() {
|
|
let existing = (try? engine.store.fetchInstruments(sessionID: session.id)) ?? []
|
|
alreadyAddedDescriptorIDs = Set(existing.map { engine.descriptor(for: $0).id })
|
|
}
|
|
|
|
private func resolveSystemParameters() async {
|
|
let devices = await engine.deviceManager.currentDevices()
|
|
guard let device = devices.first(where: { $0.id == session.deviceID }) else { return }
|
|
systemParams = await engine.systemParameters.parameters(for: device)
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if isCompactWidth {
|
|
compactBody
|
|
} else {
|
|
regularBody
|
|
}
|
|
}
|
|
.frame(minWidth: isCompactWidth ? 0 : 800, minHeight: isCompactWidth ? 0 : 420)
|
|
.fileImporter(
|
|
isPresented: $isShowingImportPicker,
|
|
allowedContentTypes: [.folder]
|
|
) { result in
|
|
if case .success(let url) = result {
|
|
importHookPack(at: url)
|
|
}
|
|
}
|
|
.alert("Import failed", isPresented: importErrorBinding, presenting: importErrorMessage) { _ in
|
|
Button("OK") { importErrorMessage = nil }
|
|
} message: { message in
|
|
Text(message)
|
|
}
|
|
.task(id: session.deviceID) { await resolveSystemParameters() }
|
|
.task(id: session.id) { refreshAlreadyAdded() }
|
|
}
|
|
|
|
private var importErrorBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { importErrorMessage != nil },
|
|
set: { if !$0 { importErrorMessage = nil } }
|
|
)
|
|
}
|
|
|
|
private var compactBody: some View {
|
|
NavigationStack(path: $compactPath) {
|
|
List {
|
|
Section {
|
|
ForEach(builtinDescriptors) { descriptor in
|
|
NavigationLink(value: descriptor.id) {
|
|
descriptorRow(descriptor)
|
|
}
|
|
}
|
|
}
|
|
Section("Custom Instruments") {
|
|
ForEach(customDescriptors) { descriptor in
|
|
NavigationLink(value: descriptor.id) {
|
|
descriptorRow(descriptor)
|
|
}
|
|
}
|
|
Button {
|
|
Task { @MainActor in
|
|
await createNewCustomAndDismiss()
|
|
}
|
|
} label: {
|
|
Label("New Custom Instrument\u{2026}", systemImage: "plus.circle")
|
|
}
|
|
.accessibilityIdentifier("addInstrument.descriptor.\(Self.newCustomDescriptorID)")
|
|
Button {
|
|
isShowingImportPicker = true
|
|
} label: {
|
|
Label("Import from Hookpack\u{2026}", systemImage: "square.and.arrow.down")
|
|
}
|
|
.accessibilityIdentifier("addInstrument.descriptor.\(Self.importHookPackDescriptorID)")
|
|
}
|
|
}
|
|
.navigationTitle("Add Instrument")
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") { dismiss() }
|
|
}
|
|
}
|
|
.navigationDestination(for: InstrumentDescriptor.ID.self) { id in
|
|
if let descriptor = descriptors.first(where: { $0.id == id }) {
|
|
detailContent(descriptor: descriptor)
|
|
.navigationTitle(descriptor.displayName)
|
|
#if canImport(UIKit)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
.toolbar { sharedToolbar }
|
|
.onAppear {
|
|
if selectedDescriptorID != descriptor.id {
|
|
selectedDescriptorID = descriptor.id
|
|
initialConfigJSON = descriptor.makeInitialConfigJSON()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var descriptorList: some View {
|
|
Section {
|
|
ForEach(builtinDescriptors) { descriptor in
|
|
descriptorRow(descriptor).tag(descriptor.id)
|
|
}
|
|
}
|
|
Section("Custom Instruments") {
|
|
ForEach(customDescriptors) { descriptor in
|
|
descriptorRow(descriptor).tag(descriptor.id)
|
|
}
|
|
HStack {
|
|
Image(systemName: "plus.circle")
|
|
Text("New Custom Instrument\u{2026}")
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.accessibilityIdentifier("addInstrument.descriptor.\(Self.newCustomDescriptorID)")
|
|
.tag(Self.newCustomDescriptorID)
|
|
HStack {
|
|
Image(systemName: "square.and.arrow.down")
|
|
Text("Import from Hookpack\u{2026}")
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.accessibilityIdentifier("addInstrument.descriptor.\(Self.importHookPackDescriptorID)")
|
|
.tag(Self.importHookPackDescriptorID)
|
|
}
|
|
}
|
|
|
|
private var regularBody: some View {
|
|
NavigationSplitView {
|
|
List(selection: $selectedDescriptorID) {
|
|
descriptorList
|
|
}
|
|
.frame(minWidth: 240, idealWidth: 260)
|
|
.listStyle(.sidebar)
|
|
.navigationTitle("Add Instrument")
|
|
} detail: {
|
|
Group {
|
|
if selectedDescriptorID == Self.newCustomDescriptorID {
|
|
newCustomDetail
|
|
} else if selectedDescriptorID == Self.importHookPackDescriptorID {
|
|
importHookPackDetail
|
|
} else if let descriptor = selectedDescriptor {
|
|
detailContent(descriptor: descriptor)
|
|
} else {
|
|
Text("Select an instrument to configure.")
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
|
}
|
|
}
|
|
.toolbar { sharedToolbar }
|
|
}
|
|
.onChange(of: selectedDescriptorID) { _, newID in
|
|
guard let id = newID else { return }
|
|
if id == Self.newCustomDescriptorID || id == Self.importHookPackDescriptorID {
|
|
initialConfigJSON = Data()
|
|
return
|
|
}
|
|
guard let desc = descriptors.first(where: { $0.id == id }) else { return }
|
|
initialConfigJSON = desc.makeInitialConfigJSON()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func createNewCustomAndDismiss() async {
|
|
let def = engine.createCustomInstrument()
|
|
let configJSON = CustomInstrumentConfig(
|
|
defID: def.id,
|
|
features: CustomInstrumentLibrary.initialFeatureStates(for: def)
|
|
).encode()
|
|
let added = await engine.addInstrument(
|
|
kind: .custom,
|
|
sourceIdentifier: def.id.uuidString,
|
|
configJSON: configJSON,
|
|
sessionID: session.id
|
|
)
|
|
if let added {
|
|
onInstrumentAdded?(added)
|
|
}
|
|
selection = .customInstrumentFile(def.id, def.entrypoint)
|
|
dismiss()
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var newCustomDetail: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Image(systemName: "wand.and.stars")
|
|
.font(.system(size: 32))
|
|
Text("Create a Custom Instrument")
|
|
.font(.headline)
|
|
Text("Custom instruments are TypeScript snippets you write inline. They are saved with the project, can be added to multiple sessions, and synchronized when collaboration is enabled. After creating one you can rename it, choose an icon, and define toggleable features from the sidebar.")
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
}
|
|
.padding()
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var importHookPackDetail: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Image(systemName: "square.and.arrow.down")
|
|
.font(.system(size: 32))
|
|
Text("Import from Hookpack")
|
|
.font(.headline)
|
|
Text("Pick a hookpack folder containing manifest.json and a TypeScript entry file. The hookpack is cloned into the project as a custom instrument with a fresh identity, so subsequent edits stay local.")
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
}
|
|
.padding()
|
|
}
|
|
|
|
@MainActor
|
|
private func importHookPack(at folderURL: URL) {
|
|
let started = folderURL.startAccessingSecurityScopedResource()
|
|
defer { if started { folderURL.stopAccessingSecurityScopedResource() } }
|
|
do {
|
|
let def = try engine.forkHookPackToCustomInstrument(folderURL: folderURL)
|
|
Task { @MainActor in
|
|
let configJSON = CustomInstrumentConfig(
|
|
defID: def.id,
|
|
features: CustomInstrumentLibrary.initialFeatureStates(for: def)
|
|
).encode()
|
|
let added = await engine.addInstrument(
|
|
kind: .custom,
|
|
sourceIdentifier: def.id.uuidString,
|
|
configJSON: configJSON,
|
|
sessionID: session.id
|
|
)
|
|
if let added {
|
|
onInstrumentAdded?(added)
|
|
}
|
|
selection = .customInstrumentFile(def.id, def.entrypoint)
|
|
dismiss()
|
|
}
|
|
} catch {
|
|
importErrorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func detailContent(descriptor: InstrumentDescriptor) -> some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
if isAlreadyAdded(descriptor) {
|
|
incompatibilityBanner(reason: "This instrument is already added to the session.")
|
|
} else if let reason = incompatibilityReason(for: descriptor) {
|
|
incompatibilityBanner(reason: reason)
|
|
}
|
|
if let ui = InstrumentUIRegistry.shared.ui(for: descriptor.id) {
|
|
ui.makeConfigEditor(
|
|
configJSON: $initialConfigJSON,
|
|
engine: engine,
|
|
selection: $selection
|
|
)
|
|
.environment(\.instrumentSession, session)
|
|
.environment(\.instrumentConfigCommitCoordinator, commitCoordinator)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
.padding()
|
|
} else {
|
|
Text("Configuration unavailable.")
|
|
.foregroundStyle(.secondary)
|
|
.padding()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func incompatibilityBanner(reason: String) -> some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(.orange)
|
|
Text(reason)
|
|
.font(.callout)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(10)
|
|
.background(Color.orange.opacity(0.12))
|
|
}
|
|
|
|
private func descriptorRow(_ descriptor: InstrumentDescriptor) -> some View {
|
|
let reason = incompatibilityReason(for: descriptor)
|
|
let alreadyAdded = isAlreadyAdded(descriptor)
|
|
let dimmed = reason != nil || alreadyAdded
|
|
let helpText = reason ?? (alreadyAdded ? "Already added" : "")
|
|
return HStack {
|
|
InstrumentIconView(icon: descriptor.icon, pointSize: 12)
|
|
Text(descriptor.displayName)
|
|
Spacer(minLength: 4)
|
|
if dimmed {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(.secondary)
|
|
.font(.system(size: 11))
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.opacity(dimmed ? 0.5 : 1)
|
|
.help(helpText)
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityIdentifier("addInstrument.descriptor.\(descriptor.id)")
|
|
}
|
|
|
|
@ToolbarContentBuilder
|
|
private var sharedToolbar: some ToolbarContent {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
}
|
|
if let pack = selectedHookPack {
|
|
ToolbarItem(placement: .automatic) {
|
|
Button("Edit a Copy\u{2026}") {
|
|
forkSelectedPack(pack)
|
|
}
|
|
.accessibilityIdentifier("addInstrument.editCopy")
|
|
}
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button(confirmActionLabel) {
|
|
commitCoordinator.flushPendingEdits()
|
|
if selectedDescriptorID == Self.importHookPackDescriptorID {
|
|
isShowingImportPicker = true
|
|
return
|
|
}
|
|
Task { @MainActor in
|
|
if selectedDescriptorID == Self.newCustomDescriptorID {
|
|
await createNewCustomAndDismiss()
|
|
return
|
|
}
|
|
if let descriptor = selectedDescriptor {
|
|
let newInstrument = await engine.addInstrument(
|
|
kind: descriptor.kind,
|
|
sourceIdentifier: descriptor.sourceIdentifier,
|
|
configJSON: initialConfigJSON,
|
|
sessionID: session.id
|
|
)
|
|
if let newInstrument {
|
|
onInstrumentAdded?(newInstrument)
|
|
}
|
|
}
|
|
dismiss()
|
|
}
|
|
}
|
|
.disabled(isConfirmActionDisabled)
|
|
.accessibilityIdentifier("addInstrument.add")
|
|
}
|
|
ToolbarItem(placement: .automatic) {
|
|
Button("Browse CodeShare…") {
|
|
onBrowseCodeShare()
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var selectedHookPack: LumaCore.HookPack? {
|
|
guard let descriptor = selectedDescriptor, descriptor.kind == .hookPack else { return nil }
|
|
return engine.hookPacks.pack(withId: descriptor.sourceIdentifier)
|
|
}
|
|
|
|
@MainActor
|
|
private func forkSelectedPack(_ pack: LumaCore.HookPack) {
|
|
do {
|
|
let def = try engine.forkHookPackToCustomInstrument(folderURL: pack.folderURL)
|
|
selection = .customInstrumentFile(def.id, def.entrypoint)
|
|
dismiss()
|
|
} catch {
|
|
importErrorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class InstrumentConfigCommitCoordinator {
|
|
private var handlers: [UUID: () -> Void] = [:]
|
|
|
|
func register(_ handler: @escaping () -> Void) -> UUID {
|
|
let id = UUID()
|
|
handlers[id] = handler
|
|
return id
|
|
}
|
|
|
|
func unregister(_ id: UUID) {
|
|
handlers.removeValue(forKey: id)
|
|
}
|
|
|
|
func flushPendingEdits() {
|
|
for handler in handlers.values { handler() }
|
|
}
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
var instrumentConfigCommitCoordinator: InstrumentConfigCommitCoordinator? {
|
|
get { self[InstrumentConfigCommitCoordinatorKey.self] }
|
|
set { self[InstrumentConfigCommitCoordinatorKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
private struct InstrumentConfigCommitCoordinatorKey: EnvironmentKey {
|
|
static let defaultValue: InstrumentConfigCommitCoordinator? = nil
|
|
}
|