Files
luma/Sources/LumaCore/Models/CustomInstrumentDef.swift

360 lines
14 KiB
Swift

import Foundation
import GRDB
public struct CustomInstrumentDef: Codable, Identifiable, Sendable, Equatable, FetchableRecord, PersistableRecord {
public static let databaseTableName = "custom_instrument_def"
public var id: UUID
public var name: String
public var icon: InstrumentIcon
public var compatibility: InstrumentCompatibility
public var entrypoint: String
public var features: [Feature]
public var widgets: [InstrumentWidget]
public var createdAt: Date
public var updatedAt: Date
public struct Feature: Codable, Identifiable, Sendable, Equatable {
public var id: String
public var name: String
public var schema: FeatureSchema
public var optional: Bool
public var enabledByDefault: Bool
public init(
id: String,
name: String,
schema: FeatureSchema = .boolean(default: true),
optional: Bool = true,
enabledByDefault: Bool = true
) {
self.id = id
self.name = name
self.schema = schema
self.optional = optional
self.enabledByDefault = enabledByDefault
}
}
public init(
id: UUID = UUID(),
name: String,
icon: InstrumentIcon = .symbolic(InstrumentIconCatalog.default.id),
compatibility: InstrumentCompatibility = .universal,
entrypoint: String,
features: [Feature] = [],
widgets: [InstrumentWidget] = [],
createdAt: Date = Date(),
updatedAt: Date = Date()
) {
self.id = id
self.name = name
self.icon = icon
self.compatibility = compatibility
self.entrypoint = entrypoint
self.features = features
self.widgets = widgets
self.createdAt = createdAt
self.updatedAt = updatedAt
}
public init(row: Row) throws {
id = UUID(uuidString: row["id"])!
name = row["name"]
icon = InstrumentIcon.decodedJSONString(row["icon"])
let compatibilityJSON: String = row["compatibility_json"]
compatibility = try JSONDecoder().decode(InstrumentCompatibility.self, from: Data(compatibilityJSON.utf8))
entrypoint = row["entrypoint"]
let featuresJSON: String = row["features_json"]
features = try JSONDecoder().decode([Feature].self, from: Data(featuresJSON.utf8))
let widgetsJSON: String = row["widgets_json"]
widgets = try JSONDecoder().decode([InstrumentWidget].self, from: Data(widgetsJSON.utf8))
createdAt = row["created_at"]
updatedAt = row["updated_at"]
}
public func encode(to container: inout PersistenceContainer) {
container["id"] = id.uuidString
container["name"] = name
container["icon"] = icon.encodedJSONString()
container["compatibility_json"] = compatibilityJSONString
container["entrypoint"] = entrypoint
container["features_json"] = featuresJSONString
container["widgets_json"] = widgetsJSONString
container["created_at"] = createdAt
container["updated_at"] = updatedAt
}
public func toJSON() -> [String: Any] {
var out: [String: Any] = [
"id": id.uuidString,
"name": name,
"icon": icon.toJSON(),
"entrypoint": entrypoint,
"features": features.map(featureToJSON),
"widgets": widgetsJSONArray,
"created_at": ISO8601DateFormatter().string(from: createdAt),
"updated_at": ISO8601DateFormatter().string(from: updatedAt),
]
if let obj = compatibilityJSONObject {
out["compatibility"] = obj
}
return out
}
public static func fromJSON(_ obj: [String: Any]) -> CustomInstrumentDef? {
guard let idStr = obj["id"] as? String, let id = UUID(uuidString: idStr),
let name = obj["name"] as? String,
let icon = InstrumentIcon.fromJSON(obj["icon"]),
let entrypoint = obj["entrypoint"] as? String
else { return nil }
let compatibility = parseCompatibility(obj["compatibility"])
let features = parseFeatures(obj["features"])
let widgets = parseWidgets(obj["widgets"])
let isoFmt = ISO8601DateFormatter()
let createdAt = (obj["created_at"] as? String).flatMap(isoFmt.date(from:)) ?? Date()
let updatedAt = (obj["updated_at"] as? String).flatMap(isoFmt.date(from:)) ?? Date()
return CustomInstrumentDef(
id: id,
name: name,
icon: icon,
compatibility: compatibility,
entrypoint: entrypoint,
features: features,
widgets: widgets,
createdAt: createdAt,
updatedAt: updatedAt
)
}
private var featuresJSONString: String {
let data = try! JSONEncoder().encode(features)
return String(decoding: data, as: UTF8.self)
}
private var widgetsJSONString: String {
let data = try! JSONEncoder().encode(widgets)
return String(decoding: data, as: UTF8.self)
}
private var compatibilityJSONString: String {
let data = try! JSONEncoder().encode(compatibility)
return String(decoding: data, as: UTF8.self)
}
private var compatibilityJSONObject: [String: Any]? {
guard !compatibility.isUniversal else { return nil }
let data = try! JSONEncoder().encode(compatibility)
return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
}
private static func parseCompatibility(_ raw: Any?) -> InstrumentCompatibility {
guard let obj = raw as? [String: Any],
let data = try? JSONSerialization.data(withJSONObject: obj),
let decoded = try? JSONDecoder().decode(InstrumentCompatibility.self, from: data)
else {
return .universal
}
return decoded
}
private var widgetsJSONArray: [Any] {
let data = try! JSONEncoder().encode(widgets)
return try! JSONSerialization.jsonObject(with: data) as! [Any]
}
private static func parseFeatures(_ raw: Any?) -> [Feature] {
guard let arr = raw as? [[String: Any]] else { return [] }
return arr.compactMap(parseFeature)
}
private static func parseWidgets(_ raw: Any?) -> [InstrumentWidget] {
guard let arr = raw as? [Any],
let data = try? JSONSerialization.data(withJSONObject: arr),
let widgets = try? JSONDecoder().decode([InstrumentWidget].self, from: data)
else { return [] }
return widgets
}
private static func parseFeature(_ obj: [String: Any]) -> Feature? {
guard let id = obj["id"] as? String, let name = obj["name"] as? String else { return nil }
let schema = parseFeatureSchema(obj["schema"]) ?? .boolean(default: false)
let optional = (obj["optional"] as? Bool) ?? true
let enabledByDefault = (obj["enabled_by_default"] as? Bool) ?? true
return Feature(id: id, name: name, schema: schema, optional: optional, enabledByDefault: enabledByDefault)
}
private static func parseFeatureSchema(_ raw: Any?) -> FeatureSchema? {
guard let obj = raw as? [String: Any] else { return nil }
let data: Data
do {
data = try JSONSerialization.data(withJSONObject: obj)
} catch {
return nil
}
return try? JSONDecoder().decode(FeatureSchema.self, from: data)
}
private func featureToJSON(_ feature: Feature) -> [String: Any] {
var out: [String: Any] = [
"id": feature.id,
"name": feature.name,
"optional": feature.optional,
"enabled_by_default": feature.enabledByDefault,
]
if let data = try? JSONEncoder().encode(feature.schema),
let schemaObj = try? JSONSerialization.jsonObject(with: data)
{
out["schema"] = schemaObj
}
return out
}
public mutating func normalize() {
for i in features.indices {
features[i].schema = CustomInstrumentDef.normalizedSchema(features[i].schema)
if case .boolean = features[i].schema, features[i].optional {
features[i].optional = false
}
}
}
private static func normalizedSchema(_ schema: FeatureSchema) -> FeatureSchema {
switch schema {
case .object(let fields):
return .object(fields: fields.map(normalizedField))
case .array(let item, let d):
return .array(item: normalizedArrayItem(item), default: d)
default:
return schema
}
}
private static func normalizedField(_ field: ObjectField) -> ObjectField {
var copy = field
copy.schema = normalizedSchema(field.schema)
if case .boolean = copy.schema, copy.optional {
copy.optional = false
}
return copy
}
private static func normalizedArrayItem(_ item: ArrayItemSchema) -> ArrayItemSchema {
switch item {
case .object(let fields):
return .object(fields: fields.map(normalizedField))
default:
return item
}
}
public static let defaultEntrypointFilename = "main.ts"
public static func defaultEntrypointFiles(defID: UUID) -> [CustomInstrumentFile] {
[CustomInstrumentFile(defID: defID, path: defaultEntrypointFilename, content: defaultEntrypointSource)]
}
public static let defaultEntrypointSource: String = """
// A custom instrument is a regular Frida agent module. It runs in
// the target process and exports an `instrument` object that the
// host loads via the standard instrument lifecycle.
//
// create(): install your hooks; return updateConfig + dispose,
// plus onAction if your list or table widgets declare
// actions, and onConsoleInput if you have a console
// widget.
// dispose(): undo every side effect (detach listeners, revert
// replacements). Save will call this and then re-create
// with the new source.
//
// Features declared in the sidebar show up on `config.features`
// typed exactly as you defined them. Accessing a feature you have
// not declared is a type error.
//
// Widgets declared in the sidebar render in the instance pane and
// are accessed via `ctx.widget(id)`:
// - counter: setCounter({ value, unit?, delta? }), clear()
// - histogram: setHistogram(buckets),
// incrementBucket(label, by?), clear()
// - graph: push({ series, x, y }), clear()
// - list: upsertItem({ id, title, subtitle?, accessory? }),
// removeItem(id), clear()
// - table: upsertRow({ id, cells }), removeRow(id), clear()
// - hex: setHex({ bytes, baseAddress? }), clear()
// - console: appendOutput(text), appendError(text),
// appendValue(value) — renders any JS value
// (objects, NativePointer, ArrayBuffer, …) using
// the same inspector the REPL uses,
// appendConsole({ kind, text }), clear()
//
// Action buttons on a list or table widget invoke onAction({
// widget, action, item }) on the handle. Submissions from a
// console widget invoke
// onConsoleInput({ widget, entryId, text }, respond)
// and `respond.output` / `respond.error` / `respond.value` post
// entries that are visually grouped with the originating input.
// For unsolicited output, call appendOutput / appendError /
// appendValue on the widget directly. The `widget` and `action`
// fields are narrowed to the ids you declared. The `restored`
// argument carries the last snapshot for each widget whose
// Persistence is set to Session — widgets left at None do not
// appear on `restored`.
//
// The example below assumes:
// - one feature `logStack` (Boolean)
// - one graph widget `opens` with series `length` and
// Persistence = Session (required for `restored.opens`
// to type-check)
// - one list widget `paths` with action `bookmark`
// - one console widget `repl`
// Until you add them, the lines referencing them are type
// errors — add them via right-click → Features… / Widgets…,
// or delete the corresponding lines.
export const instrument: CustomInstrument = {
create(ctx, config, restored) {
let current = config;
const listeners: InvocationListener[] = [];
const bookmarks = new Set<string>();
let count = restored.opens.points.length;
if (Process.platform !== "windows") {
listeners.push(Interceptor.attach(Module.getGlobalExportByName("open"), {
onEnter(args) {
const path = args[0].readUtf8String()!;
ctx.emit({ syscall: "open", path });
ctx.widget("opens").push({ series: "length", x: ++count, y: path.length });
ctx.widget("paths").upsertItem({ id: path, title: path });
if (current.features.logStack) {
ctx.emit({ stack: this.context.sp.readByteArray(64) });
}
}
}));
}
return {
updateConfig(next) {
current = next;
},
onAction(action) {
if (action.widget === "paths" && action.action === "bookmark") {
bookmarks.add(action.item);
ctx.emit({ bookmarked: action.item });
}
},
onConsoleInput({ widget, text }, respond) {
if (widget === "repl") {
respond.output(`echo: ${text}`);
}
},
dispose() {
for (const l of listeners) {
l.detach();
}
}
};
}
};
"""
}