Drop the Monaco webview. Edit in a GtkSourceView on GTK and in a native text view on Apple, both speaking to Frida.Compiler's in-process language server for completions, signature help, hovers and diagnostics. Color, complete and indent code natively, matching the GitHub theme in light and dark.
400 lines
12 KiB
Swift
400 lines
12 KiB
Swift
import CryptoKit
|
|
import SwiftUI
|
|
import LumaCore
|
|
|
|
struct CodeShareBrowserView: View {
|
|
let session: LumaCore.ProcessSession
|
|
let engine: Engine
|
|
let onInstrumentAdded: ((LumaCore.InstrumentInstance) -> Void)?
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@State private var mode: CodeShareService.Mode = .popular
|
|
@State private var searchQuery: String = ""
|
|
@State private var projects: [CodeShareService.ProjectSummary] = []
|
|
@State private var selectedProject: CodeShareService.ProjectSummary?
|
|
@State private var isLoading: Bool = false
|
|
@State private var loadError: String?
|
|
@State private var addInstrumentHandler: (() -> Void)?
|
|
@State private var isAddingInstrument: Bool = false
|
|
|
|
#if canImport(UIKit)
|
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
private var isCompactWidth: Bool { horizontalSizeClass == .compact }
|
|
#else
|
|
private var isCompactWidth: Bool { false }
|
|
#endif
|
|
|
|
var body: some View {
|
|
NavigationSplitView {
|
|
sidebar
|
|
.navigationTitle("CodeShare")
|
|
} detail: {
|
|
detailView
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
.padding()
|
|
}
|
|
.frame(minWidth: isCompactWidth ? 0 : 900, minHeight: isCompactWidth ? 0 : 600)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Add Instrument") {
|
|
addInstrumentHandler?()
|
|
}
|
|
.disabled(addInstrumentHandler == nil || isAddingInstrument)
|
|
}
|
|
}
|
|
.onAppear {
|
|
Task {
|
|
await loadPopular()
|
|
}
|
|
}
|
|
.onChange(of: searchQuery) { _, newValue in
|
|
Task {
|
|
await performSearchDebounced(query: newValue)
|
|
}
|
|
}
|
|
.onChange(of: selectedProject) { _, _ in
|
|
addInstrumentHandler = nil
|
|
isAddingInstrument = false
|
|
}
|
|
}
|
|
|
|
private var sidebar: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Picker(
|
|
"Mode",
|
|
selection: Binding<Int>(
|
|
get: {
|
|
switch mode {
|
|
case .popular:
|
|
return 0
|
|
case .search:
|
|
return 1
|
|
}
|
|
},
|
|
set: { idx in
|
|
switch idx {
|
|
case 0:
|
|
mode = .popular
|
|
projects = []
|
|
selectedProject = nil
|
|
Task { await loadPopular() }
|
|
default:
|
|
mode = .search(query: searchQuery)
|
|
projects = []
|
|
selectedProject = nil
|
|
Task { await performSearch() }
|
|
}
|
|
}
|
|
)
|
|
) {
|
|
Text("Popular").tag(0)
|
|
Text("Search").tag(1)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
|
|
if case .search = mode {
|
|
HStack {
|
|
TextField("Search CodeShare", text: $searchQuery)
|
|
.textFieldStyle(.roundedBorder)
|
|
.disabled(isLoading)
|
|
|
|
if isLoading {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
}
|
|
|
|
if let loadError {
|
|
Text(loadError)
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
}
|
|
|
|
ZStack {
|
|
List(selection: $selectedProject) {
|
|
ForEach(projects) { project in
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(project.name)
|
|
|
|
Text("@\(project.owner)/\(project.slug)")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "heart.fill")
|
|
.font(.caption2)
|
|
Text("\(project.likes)")
|
|
.font(.caption2)
|
|
}
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.tag(project)
|
|
}
|
|
}
|
|
.listStyle(.sidebar)
|
|
|
|
if case .popular = mode, isLoading && projects.isEmpty {
|
|
ProgressView()
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
.frame(minWidth: isCompactWidth ? 0 : 260, idealWidth: isCompactWidth ? nil : 300)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var detailView: some View {
|
|
if let project = selectedProject {
|
|
CodeShareProjectDetailView(
|
|
project: project,
|
|
engine: engine,
|
|
session: session,
|
|
registerAddAction: { handler in
|
|
addInstrumentHandler = handler
|
|
},
|
|
isAddingInstrument: $isAddingInstrument,
|
|
onInstrumentAdded: { instance in
|
|
onInstrumentAdded?(instance)
|
|
dismiss()
|
|
}
|
|
)
|
|
} else {
|
|
Text("Select a snippet to preview and add it as an instrument.")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private func loadPopular() async {
|
|
isLoading = true
|
|
loadError = nil
|
|
do {
|
|
let items = try await CodeShareService.fetchPopular()
|
|
projects = items
|
|
if selectedProject == nil, !isCompactWidth {
|
|
selectedProject = projects.first
|
|
}
|
|
} catch {
|
|
loadError = String(describing: error)
|
|
projects = []
|
|
selectedProject = nil
|
|
}
|
|
isLoading = false
|
|
}
|
|
|
|
private func performSearch() async {
|
|
guard case .search = mode else { return }
|
|
isLoading = true
|
|
loadError = nil
|
|
do {
|
|
let items = try await CodeShareService.searchProjects(query: searchQuery)
|
|
projects = items
|
|
if selectedProject == nil, !isCompactWidth {
|
|
selectedProject = projects.first
|
|
}
|
|
} catch {
|
|
loadError = String(describing: error)
|
|
projects = []
|
|
selectedProject = nil
|
|
}
|
|
isLoading = false
|
|
}
|
|
|
|
private func performSearchDebounced(query: String) async {
|
|
guard case .search = mode else { return }
|
|
try? await Task.sleep(nanoseconds: 300_000_000)
|
|
if query == searchQuery {
|
|
await performSearch()
|
|
}
|
|
}
|
|
}
|
|
|
|
struct CodeShareProjectDetailView: View {
|
|
let project: CodeShareService.ProjectSummary
|
|
let engine: Engine
|
|
let session: LumaCore.ProcessSession
|
|
let registerAddAction: (((() -> Void)?) -> Void)
|
|
@Binding var isAddingInstrument: Bool
|
|
let onInstrumentAdded: (LumaCore.InstrumentInstance) -> Void
|
|
|
|
@State private var details: CodeShareService.ProjectDetails?
|
|
@State private var source: String = ""
|
|
@State private var isLoadingDetails = false
|
|
@State private var loadError: String?
|
|
@StateObject private var introspector = CodeIntrospector()
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
header
|
|
|
|
if let loadError {
|
|
Text(loadError)
|
|
.foregroundStyle(.red)
|
|
}
|
|
|
|
if let details {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(details.description)
|
|
.font(.subheadline)
|
|
|
|
Text("Frida \(details.fridaVersion)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Divider()
|
|
|
|
Text("Source")
|
|
.font(.headline)
|
|
|
|
CodeEditorView(
|
|
text: $source,
|
|
profile: EditorProfile.fridaCodeShare(),
|
|
introspector: introspector,
|
|
engine: engine,
|
|
)
|
|
}
|
|
} else if isLoadingDetails {
|
|
Text("Loading…")
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
Text("Select a snippet to load details.")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.task(id: project.id) {
|
|
await loadDetails()
|
|
}
|
|
.onDisappear {
|
|
registerAddAction(nil)
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(project.name)
|
|
.font(.title3.weight(.semibold))
|
|
|
|
Text("@\(project.owner)/\(project.slug)")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "heart.fill")
|
|
.font(.caption2)
|
|
Text("\(project.likes) likes")
|
|
.font(.caption2)
|
|
}
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private func loadDetails() async {
|
|
isLoadingDetails = true
|
|
loadError = nil
|
|
details = nil
|
|
registerAddAction(nil)
|
|
|
|
let currentID = project.id
|
|
|
|
defer {
|
|
if project.id == currentID {
|
|
isLoadingDetails = false
|
|
}
|
|
}
|
|
|
|
do {
|
|
let d = try await CodeShareService.fetchProjectDetails(
|
|
owner: project.owner,
|
|
slug: project.slug
|
|
)
|
|
|
|
guard project.id == currentID else { return }
|
|
|
|
details = d
|
|
source = d.source
|
|
|
|
registerAddAction {
|
|
Task { @MainActor in
|
|
guard !isAddingInstrument else { return }
|
|
isAddingInstrument = true
|
|
defer { isAddingInstrument = false }
|
|
await addInstrument(details: d)
|
|
}
|
|
}
|
|
} catch is CancellationError {
|
|
return
|
|
} catch {
|
|
guard project.id == currentID else { return }
|
|
loadError = String(describing: error)
|
|
details = nil
|
|
source = ""
|
|
}
|
|
}
|
|
|
|
private func addInstrument(details: CodeShareService.ProjectDetails) async {
|
|
let symbols = await introspector.topLevelSymbols()
|
|
|
|
let projectRef = CodeShareProjectRef(
|
|
id: details.id,
|
|
owner: details.owner,
|
|
slug: details.slug
|
|
)
|
|
|
|
let finalSource = source
|
|
|
|
let hash: String = {
|
|
let data = Data(finalSource.utf8)
|
|
let digest = SHA256.hash(data: data)
|
|
return digest.map { String(format: "%02x", $0) }.joined()
|
|
}()
|
|
|
|
let cfg = CodeShareConfig(
|
|
name: details.name,
|
|
description: details.description,
|
|
source: finalSource,
|
|
exports: symbols.map(\.text),
|
|
project: projectRef,
|
|
lastSyncedHash: hash,
|
|
lastReviewedHash: hash,
|
|
fridaVersion: details.fridaVersion,
|
|
allowRemoteUpdates: false
|
|
)
|
|
|
|
guard let configData = try? JSONEncoder().encode(cfg) else {
|
|
return
|
|
}
|
|
|
|
let sourceIdentifier = "@\(details.owner)/\(details.slug)"
|
|
|
|
let descriptor = InstrumentDescriptor(
|
|
id: "codeshare:\(sourceIdentifier)",
|
|
kind: .codeShare,
|
|
sourceIdentifier: sourceIdentifier,
|
|
displayName: cfg.name,
|
|
icon: .symbolic("cloud"),
|
|
makeInitialConfigJSON: { configData }
|
|
)
|
|
|
|
engine.registerDescriptor(descriptor)
|
|
InstrumentUIRegistry.shared.register(for: descriptor.id, ui: CodeShareUI())
|
|
|
|
let newInstrument = await engine.addInstrument(
|
|
kind: descriptor.kind,
|
|
sourceIdentifier: descriptor.sourceIdentifier,
|
|
configJSON: configData,
|
|
sessionID: session.id
|
|
)
|
|
|
|
if let newInstrument {
|
|
onInstrumentAdded(newInstrument)
|
|
}
|
|
}
|
|
}
|