diff --git a/README.md b/README.md index 0fe321d0..066fbe3f 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,10 @@ ## Features - On-device debugging/Just-In-Time (JIT) compilation for supported apps via [`idevice`](https://github.com/jkcoxson/idevice). - Seamless integration with our custom built loopback vpn. -- Native UI for managing debugging/JIT-enabling. -- No data collection—ensuring full privacy. +- Native UI for managing debugging/JIT-enabling. +- Optional block-based script editor for building scripts using a Scratch-like interface. +- Block editor provides quick-insert options for common values and color-coded blocks for a Scratch-like feel. +- No data collection—ensuring full privacy. ## License StikDebug is licensed under **AGPL-3.0**. See [`LICENSE`](LICENSE) for details. diff --git a/StikJIT/JSSupport/BlockScript.swift b/StikJIT/JSSupport/BlockScript.swift new file mode 100644 index 00000000..96206d2b --- /dev/null +++ b/StikJIT/JSSupport/BlockScript.swift @@ -0,0 +1,104 @@ +import Foundation +import SwiftUI + +enum BlockType: String, Codable, CaseIterable, Identifiable { + case sendCommand + case log + case getPid + case hasTXM + case prepareMemoryRegion + + var id: String { rawValue } + + var placeholder: String { + switch self { + case .sendCommand: + return "Command" + case .log: + return "Message" + case .prepareMemoryRegion: + return "startAddr,size" + case .getPid, .hasTXM: + return "" + } + } + + /// Suggested values that can be inserted when editing a block of this type. + /// These are just examples to help users build scripts quickly. + var options: [String] { + switch self { + case .sendCommand: + return ["vAttach;${pid}", "D", "c"] + case .log: + return ["Hello", "Done"] + case .prepareMemoryRegion: + return ["0x0,0x1000", "0x100000000,0x2000"] + case .getPid, .hasTXM: + return [] + } + } + + /// Color used for displaying the block in the editor so it looks + /// similar to a Scratch block category. + var color: Color { + switch self { + case .sendCommand: + return .blue + case .log: + return .green + case .getPid, .hasTXM: + return .orange + case .prepareMemoryRegion: + return .purple + } + } +} + +struct Block: Codable, Identifiable { + var id = UUID() + var type: BlockType = .sendCommand + var value: String = "" +} + +struct BlockScript: Codable { + var blocks: [Block] = [] + + func generateJS() -> String { + blocks.map { block in + let escaped = block.value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + switch block.type { + case .sendCommand: + return "send_command(\"\(escaped)\")" + case .log: + return "log(\"\(escaped)\")" + case .getPid: + return "get_pid()" + case .hasTXM: + return "hasTXM()" + case .prepareMemoryRegion: + return "prepare_memory_region(\(block.value))" + } + }.joined(separator: "\n") + } + + static func load(from url: URL) -> BlockScript { + if let data = try? Data(contentsOf: url), + let script = try? JSONDecoder().decode(BlockScript.self, from: data) { + return script + } + return BlockScript() + } + + func save(to url: URL) { + if let data = try? JSONEncoder().encode(self) { + try? data.write(to: url) + } + } + + func saveAsJS(to url: URL) { + let js = generateJS() + try? js.write(to: url, atomically: true, encoding: .utf8) + } +} diff --git a/StikJIT/JSSupport/BlockScriptEditorView.swift b/StikJIT/JSSupport/BlockScriptEditorView.swift new file mode 100644 index 00000000..3b019616 --- /dev/null +++ b/StikJIT/JSSupport/BlockScriptEditorView.swift @@ -0,0 +1,98 @@ +import SwiftUI +import Foundation + +struct BlockScriptEditorView: View { + let scriptURL: URL + @State private var script = BlockScript() + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + List { + ForEach($script.blocks) { $block in + BlockRow(block: $block) + } + .onDelete { script.blocks.remove(atOffsets: $0) } + .onMove { script.blocks.move(fromOffsets: $0, toOffset: $1) } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .toolbar { EditButton() } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Divider() + + HStack { + Button("Cancel") { dismiss() } + .buttonStyle(.bordered) + Spacer() + Button("Save") { + saveScript() + dismiss() + } + .buttonStyle(.borderedProminent) + } + .padding() + } + .navigationTitle(scriptURL.lastPathComponent) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Menu("Add Block") { + ForEach(BlockType.allCases) { type in + Button(type.rawValue) { + script.blocks.append(Block(type: type)) + } + } + } + } + } + .onAppear(perform: loadScript) + } + + private func loadScript() { + script = BlockScript.load(from: scriptURL) + } + + private func saveScript() { + script.save(to: scriptURL) + let jsURL = scriptURL.deletingPathExtension().appendingPathExtension("js") + script.saveAsJS(to: jsURL) + } +} + +struct BlockRow: View { + @Binding var block: Block + + var body: some View { + HStack(spacing: 8) { + Picker("Type", selection: $block.type) { + ForEach(BlockType.allCases) { type in + Text(type.rawValue).tag(type) + } + } + .pickerStyle(.menu) + + if !block.type.placeholder.isEmpty { + TextField(block.type.placeholder, text: $block.value) + .textFieldStyle(.roundedBorder) + + if !block.type.options.isEmpty { + Menu { + ForEach(block.type.options, id: \.self) { option in + Button(option) { block.value = option } + } + } label: { + Image(systemName: "plus.circle") + } + } + } else { + Text(block.type.rawValue) + .foregroundStyle(.secondary) + } + } + .padding(8) + .background(block.type.color.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} diff --git a/StikJIT/JSSupport/ScriptListView.swift b/StikJIT/JSSupport/ScriptListView.swift index e9936e14..09ff5141 100644 --- a/StikJIT/JSSupport/ScriptListView.swift +++ b/StikJIT/JSSupport/ScriptListView.swift @@ -7,6 +7,7 @@ import SwiftUI import UniformTypeIdentifiers +import Foundation struct ScriptListView: View { @State private var scripts: [URL] = [] @@ -14,6 +15,7 @@ struct ScriptListView: View { @State private var newFileName = "" @State private var showImporter = false @AppStorage("DefaultScriptName") private var defaultScriptName = "attachDetach.js" + @AppStorage("useBlockEditor") private var useBlockEditor = false var onSelectScript: ((URL?) -> Void)? = nil @@ -42,7 +44,11 @@ struct ScriptListView: View { } } else { NavigationLink { - ScriptEditorView(scriptURL: script) + if script.pathExtension.lowercased() == "jsb" { + BlockScriptEditorView(scriptURL: script) + } else { + ScriptEditorView(scriptURL: script) + } } label: { HStack { Text(script.lastPathComponent) @@ -76,6 +82,7 @@ struct ScriptListView: View { .toolbar { ToolbarItem(placement: .primaryAction) { Button { + newFileName = useBlockEditor ? "script.jsb" : "script.js" showNewFileAlert = true } label: { Label("New Script", systemImage: "plus") @@ -91,13 +98,16 @@ struct ScriptListView: View { } .onAppear(perform: loadScripts) .alert("New Script", isPresented: $showNewFileAlert) { - TextField("Filename", text: $newFileName) + TextField(useBlockEditor ? "Filename.jsb" : "Filename.js", text: $newFileName) Button("Create", action: createNewScript) Button("Cancel", role: .cancel) { } } .fileImporter( isPresented: $showImporter, - allowedContentTypes: [UTType(filenameExtension: "js") ?? .plainText], + allowedContentTypes: [ + UTType(filenameExtension: "js") ?? .plainText, + UTType(filenameExtension: "jsb") ?? .plainText + ], allowsMultipleSelection: false ) { result in switch result { @@ -158,7 +168,7 @@ struct ScriptListView: View { scripts = (try? FileManager .default .contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))? - .filter { $0.pathExtension.lowercased() == "js" } ?? [] + .filter { ["js", "jsb"].contains($0.pathExtension.lowercased()) } ?? [] } private func saveDefaultScript(_ url: URL) { @@ -168,8 +178,14 @@ struct ScriptListView: View { private func createNewScript() { guard !newFileName.isEmpty else { return } var filename = newFileName - if !filename.hasSuffix(".js") { - filename += ".js" + if useBlockEditor { + if !filename.hasSuffix(".jsb") { + filename += ".jsb" + } + } else { + if !filename.hasSuffix(".js") { + filename += ".js" + } } let newURL = scriptsDirectory().appendingPathComponent(filename) @@ -181,7 +197,14 @@ struct ScriptListView: View { } do { - try "".write(to: newURL, atomically: true, encoding: .utf8) + if useBlockEditor { + let script = BlockScript() + script.save(to: newURL) + let jsURL = newURL.deletingPathExtension().appendingPathExtension("js") + script.saveAsJS(to: jsURL) + } else { + try "".write(to: newURL, atomically: true, encoding: .utf8) + } newFileName = "" loadScripts() } catch { @@ -192,6 +215,10 @@ struct ScriptListView: View { private func deleteScript(_ url: URL) { do { try FileManager.default.removeItem(at: url) + if url.pathExtension.lowercased() == "jsb" { + let jsURL = url.deletingPathExtension().appendingPathExtension("js") + try? FileManager.default.removeItem(at: jsURL) + } if url.lastPathComponent == defaultScriptName { UserDefaults.standard.removeObject(forKey: "DefaultScriptName") } diff --git a/StikJIT/Views/HomeView.swift b/StikJIT/Views/HomeView.swift index 49d2a5b0..628fbf6c 100644 --- a/StikJIT/Views/HomeView.swift +++ b/StikJIT/Views/HomeView.swift @@ -8,6 +8,7 @@ import SwiftUI import UniformTypeIdentifiers import Pipify +import Foundation struct JITEnableConfiguration { var bundleID: String? = nil @@ -454,14 +455,19 @@ struct HomeView: View { if scriptData == nil, let scriptName { let selectedScriptURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] .appendingPathComponent("scripts").appendingPathComponent(scriptName) - + if FileManager.default.fileExists(atPath: selectedScriptURL.path) { do { - scriptData = try Data(contentsOf: selectedScriptURL) + if selectedScriptURL.pathExtension.lowercased() == "jsb" { + let block = BlockScript.load(from: selectedScriptURL) + scriptData = block.generateJS().data(using: .utf8) + } else { + scriptData = try Data(contentsOf: selectedScriptURL) + } } catch { print("failed to load data from script \(error)") } - + } } } else { diff --git a/StikJIT/Views/SettingsView.swift b/StikJIT/Views/SettingsView.swift index fe32932a..1f0c4968 100644 --- a/StikJIT/Views/SettingsView.swift +++ b/StikJIT/Views/SettingsView.swift @@ -14,6 +14,7 @@ struct SettingsView: View { @AppStorage("useDefaultScript") private var useDefaultScript = false @AppStorage("enableAdvancedOptions") private var enableAdvancedOptions = false @AppStorage("enablePiP") private var enablePiP = false + @AppStorage("useBlockEditor") private var useBlockEditor = false @State private var isShowingPairingFilePicker = false @Environment(\.colorScheme) private var colorScheme @@ -284,6 +285,21 @@ struct SettingsView: View { Toggle("Picture in Picture", isOn: $enablePiP) .foregroundColor(.primary) .padding(.vertical, 6) + Toggle(isOn: $useBlockEditor) { + HStack(spacing: 6) { + Text("Use Block Script Editor") + Text("Beta") + .font(.caption2) + .fontWeight(.semibold) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .foregroundColor(.orange) + .background(Color.orange.opacity(0.2)) + .cornerRadius(4) + } + } + .foregroundColor(.primary) + .padding(.vertical, 6) } } .padding(.vertical, 20) @@ -292,9 +308,10 @@ struct SettingsView: View { if !newValue { useDefaultScript = false enablePiP = false + useBlockEditor = false } - } - } + } + } // About section SettingsCard {