mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-16 12:30:29 +00:00
630 lines
24 KiB
Swift
630 lines
24 KiB
Swift
import Foundation
|
|
import UniformTypeIdentifiers
|
|
import SwiftUI
|
|
import WebKit
|
|
import JavaScriptCore
|
|
|
|
final class MiniToolRuntime: NSObject, ObservableObject {
|
|
let tool: MiniToolBundle
|
|
let toolInfo: ToolInfo
|
|
@Published var logs: [String] = []
|
|
@Published var isReady: Bool = false
|
|
|
|
var webView: WKWebView?
|
|
private var context: JSContext?
|
|
|
|
private var appXHRTasks: [String: URLSessionDataTask] = [:]
|
|
|
|
private let messageHandlerName = "miniToolBridge"
|
|
|
|
private var ideviceJSBridge : IDeviceJSBridge? = nil
|
|
|
|
init(tool: MiniToolBundle, toolInfo: ToolInfo) {
|
|
self.tool = tool
|
|
self.toolInfo = toolInfo
|
|
super.init()
|
|
let configuration = WKWebViewConfiguration()
|
|
let controller = WKUserContentController()
|
|
controller.addUserScript(WKUserScript(source: MiniToolRuntime.frontendBridgeScript,
|
|
injectionTime: .atDocumentStart,
|
|
forMainFrameOnly: true))
|
|
configuration.userContentController = controller
|
|
let leakAvoider = LeakAvoider(delegate: self)
|
|
configuration.setURLSchemeHandler(leakAvoider, forURLScheme: "app")
|
|
webView = WKWebView(frame: .zero, configuration: configuration)
|
|
controller.add(leakAvoider, name: messageHandlerName)
|
|
webView!.navigationDelegate = self
|
|
webView!.isInspectable = true
|
|
}
|
|
|
|
deinit {
|
|
webView!.configuration.userContentController.removeScriptMessageHandler(forName: messageHandlerName)
|
|
}
|
|
|
|
func start() {
|
|
DispatchQueue.main.async {
|
|
self.logs.removeAll()
|
|
}
|
|
loadBackground()
|
|
loadFrontend()
|
|
}
|
|
|
|
func reload() {
|
|
start()
|
|
}
|
|
|
|
// MARK: - Loading
|
|
|
|
private func loadFrontend() {
|
|
guard FileManager.default.fileExists(atPath: tool.indexURL.path) else {
|
|
appendLog("index.html missing for \(tool.name)")
|
|
return
|
|
}
|
|
isReady = false
|
|
webView!.load(URLRequest(url: URL(string: "app://\(tool.getHostName())")!))
|
|
}
|
|
|
|
private func loadBackground() {
|
|
context = JSContext()
|
|
ideviceJSBridge = IDeviceJSBridge(context: context, allowedFunctions: toolInfo.requiredIDeviceFunctions ?? [])
|
|
context?.exceptionHandler = { [weak self] _, exception in
|
|
if let message = exception?.toString() {
|
|
self?.appendLog("Background exception: \(message)")
|
|
}
|
|
}
|
|
|
|
let sendToFrontend: @convention(block) (Any?) -> Void = { [weak self] payload in
|
|
self?.deliverToFrontend(payload ?? NSNull())
|
|
}
|
|
|
|
let logFunction: @convention(block) (Any?) -> Void = { [weak self] msg in
|
|
self?.appendLog(msg as? String ?? "Unable to decode log message.")
|
|
|
|
}
|
|
|
|
let ideviceFunction: @convention(block) (Any?) -> JSValue = { [weak self] msg in
|
|
return JSValue.init(newPromiseIn: self?.context!) { resolve, reject in
|
|
if let msg = msg as? [String: Any] {
|
|
if let bridge = self?.ideviceJSBridge {
|
|
bridge.didReceiveScriptMessage(msg, resolve: resolve, reject: reject);
|
|
} else {
|
|
resolve?.call(withArguments: ["Current Runtime is Terminated."])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
context?.setObject(sendToFrontend, forKeyedSubscript: "__miniToolPostMessage" as NSString)
|
|
context?.setObject(logFunction, forKeyedSubscript: "__miniToolLog" as NSString)
|
|
context?.setObject(ideviceFunction, forKeyedSubscript: "__postIdeviceMessage" as NSString)
|
|
|
|
context?.evaluateScript(MiniToolRuntime.backgroundBridgeScript)
|
|
if let ideviceJSURL = Bundle.main.url(forResource: "idevice", withExtension: "js"),
|
|
let ideviceJSText = try? String(contentsOf: ideviceJSURL, encoding: .utf8) {
|
|
context?.evaluateScript(ideviceJSText)
|
|
}
|
|
|
|
do {
|
|
let script = try String(contentsOf: tool.backgroundURL)
|
|
context?.evaluateScript(script)
|
|
} catch {
|
|
appendLog("Failed to load background.js: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
private func deliverToBackground(_ payload: Any) {
|
|
guard let receiver = context?.objectForKeyedSubscript("__miniToolReceive"),
|
|
!receiver.isUndefined else {
|
|
appendLog("Background handler is not ready")
|
|
return
|
|
}
|
|
_ = receiver.call(withArguments: [payload])
|
|
}
|
|
|
|
private func deliverToFrontend(_ payload: Any) {
|
|
guard let json = MiniToolRuntime.encodePayload(payload) else {
|
|
appendLog("Unable to encode payload for frontend")
|
|
return
|
|
}
|
|
DispatchQueue.main.async {
|
|
let script = "window.miniTool && window.miniTool.__receive(\(json))"
|
|
self.webView!.evaluateJavaScript(script) { _, error in
|
|
if let error {
|
|
self.appendLog("Frontend dispatch error: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func appendLog(_ text: String) {
|
|
DispatchQueue.main.async {
|
|
self.logs.append(text)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension MiniToolRuntime : WKURLSchemeHandler {
|
|
func webView(
|
|
_ webView: WKWebView,
|
|
start urlSchemeTask: WKURLSchemeTask
|
|
) {
|
|
guard let url = urlSchemeTask.request.url else { return }
|
|
|
|
guard let host = url.host(), host == tool.getHostName() else {
|
|
urlSchemeTask.didFailWithError(NSError(domain: "Invalid Host Name", code: 404))
|
|
return
|
|
}
|
|
|
|
let path = url.path.isEmpty ? "index.html" : url.path
|
|
|
|
let fileURL = tool.url
|
|
.appendingPathComponent(path).standardizedFileURL
|
|
|
|
if fileURL.path().hasPrefix(tool.url.path()) {
|
|
urlSchemeTask.didFailWithError(NSError(domain: "Path traversal is not allowed.", code: -1))
|
|
return
|
|
}
|
|
|
|
if fileURL.path() == tool.url.appending(path: "check").path() {
|
|
urlSchemeTask.didFailWithError(NSError(domain: "Reading this file is not allowed.", code: -1))
|
|
return
|
|
}
|
|
|
|
do {
|
|
let data = try Data(contentsOf: fileURL)
|
|
let mimeType : String
|
|
if let type = UTType(filenameExtension: fileURL.pathExtension), let mimetype = type.preferredMIMEType {
|
|
mimeType = mimetype
|
|
} else {
|
|
mimeType = UTType.data.preferredMIMEType!
|
|
}
|
|
|
|
let response = URLResponse(
|
|
url: url,
|
|
mimeType: mimeType,
|
|
expectedContentLength: data.count,
|
|
textEncodingName: nil
|
|
)
|
|
|
|
urlSchemeTask.didReceive(response)
|
|
urlSchemeTask.didReceive(data)
|
|
urlSchemeTask.didFinish()
|
|
} catch {
|
|
urlSchemeTask.didFailWithError(error)
|
|
}
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) {
|
|
|
|
}
|
|
}
|
|
|
|
// MARK: - WKScriptMessageHandler
|
|
|
|
extension MiniToolRuntime: WKScriptMessageHandler {
|
|
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
guard message.name == messageHandlerName else { return }
|
|
if let dict = message.body as? [String: Any], dict["__appXHR"] as? Bool == true {
|
|
handleAppXHRMessage(dict)
|
|
return
|
|
}
|
|
if let dict = message.body as? [String: Any], let payload = dict["payload"] {
|
|
deliverToBackground(payload)
|
|
} else {
|
|
deliverToBackground(message.body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - WKNavigationDelegate
|
|
|
|
extension MiniToolRuntime: WKNavigationDelegate {
|
|
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
|
|
// Don't allow loading other contents
|
|
guard let url = navigationAction.request.url else {
|
|
return WKNavigationActionPolicy.cancel
|
|
}
|
|
guard let scheme = url.scheme, scheme == "app" else {
|
|
return WKNavigationActionPolicy.cancel
|
|
}
|
|
guard let host = url.host(), host == tool.getHostName() else {
|
|
return WKNavigationActionPolicy.cancel
|
|
}
|
|
return WKNavigationActionPolicy.allow
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
isReady = true
|
|
deliverToBackground(["type": "ui-ready", "tool": tool.name])
|
|
deliverToFrontend(["type": "ready", "tool": tool.name])
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
|
|
download.delegate = self
|
|
}
|
|
|
|
}
|
|
|
|
extension MiniToolRuntime: WKDownloadDelegate {
|
|
|
|
func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String) async -> URL? {
|
|
return MiniToolStore.toolsDataDirectory().appending(path: suggestedFilename, directoryHint: .notDirectory)
|
|
}
|
|
}
|
|
|
|
// MARK: - Scripts & Encoding
|
|
|
|
extension MiniToolRuntime {
|
|
static let frontendBridgeScript = """
|
|
window.miniTool = window.miniTool || {};
|
|
window.miniTool.__handler = null;
|
|
window.miniTool.onMessage = function(handler) { window.miniTool.__handler = handler; };
|
|
window.miniTool.postMessage = function(payload) {
|
|
window.webkit.messageHandlers.miniToolBridge.postMessage({ payload: payload });
|
|
};
|
|
window.miniTool.__receive = function(payload) {
|
|
try {
|
|
if (typeof window.miniTool.__handler === 'function') {
|
|
window.miniTool.__handler(payload);
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
(function() {
|
|
const handler = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.miniToolBridge;
|
|
function safePostMessage(body) {
|
|
if (!handler || !handler.postMessage) {
|
|
console.error('miniToolBridge unavailable for AppXMLHttpRequest');
|
|
return;
|
|
}
|
|
handler.postMessage(body);
|
|
}
|
|
|
|
function base64ToArrayBuffer(base64) {
|
|
const binaryString = atob(base64);
|
|
const len = binaryString.length;
|
|
const bytes = new Uint8Array(len);
|
|
for (let i = 0; i < len; i++) {
|
|
bytes[i] = binaryString.charCodeAt(i);
|
|
}
|
|
return bytes.buffer;
|
|
}
|
|
|
|
class AppXMLHttpRequest {
|
|
constructor() {
|
|
this.readyState = 0;
|
|
this.status = 0;
|
|
this.statusText = '';
|
|
this.responseText = '';
|
|
this.response = null;
|
|
this.responseType = '';
|
|
this.onreadystatechange = null;
|
|
this.onload = null;
|
|
this.onerror = null;
|
|
this.onabort = null;
|
|
this._headers = {};
|
|
this._method = null;
|
|
this._url = null;
|
|
this._async = true;
|
|
this._aborted = false;
|
|
this._id = AppXMLHttpRequest.__nextId();
|
|
}
|
|
|
|
open(method, url, async = true) {
|
|
this._method = method;
|
|
this._url = url;
|
|
this._async = async !== false;
|
|
this.readyState = 1; // OPENED
|
|
this._emitReadyState();
|
|
}
|
|
|
|
setRequestHeader(key, value) {
|
|
this._headers[key] = value;
|
|
}
|
|
|
|
send(body = null) {
|
|
if (!this._method || !this._url) {
|
|
throw new Error('AppXMLHttpRequest: call open() before send().');
|
|
}
|
|
|
|
this.readyState = 2; // HEADERS_RECEIVED (simulated)
|
|
this._emitReadyState();
|
|
|
|
AppXMLHttpRequest.__pending[this._id] = this;
|
|
|
|
let encodedBody = null;
|
|
let bodyIsBase64 = false;
|
|
if (typeof body === 'string') {
|
|
encodedBody = body;
|
|
} else if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
|
|
const view = body instanceof ArrayBuffer ? new Uint8Array(body) : new Uint8Array(body.buffer, body.byteOffset || 0, body.byteLength || body.length || 0);
|
|
let binary = '';
|
|
for (let i = 0; i < view.length; i++) {
|
|
binary += String.fromCharCode(view[i]);
|
|
}
|
|
encodedBody = btoa(binary);
|
|
bodyIsBase64 = true;
|
|
} else if (body != null) {
|
|
try {
|
|
encodedBody = JSON.stringify(body);
|
|
} catch (err) {
|
|
console.error('AppXMLHttpRequest: unable to serialize body', err);
|
|
}
|
|
}
|
|
|
|
safePostMessage({
|
|
__appXHR: true,
|
|
action: 'request',
|
|
id: this._id,
|
|
method: this._method,
|
|
url: this._url,
|
|
async: this._async,
|
|
headers: this._headers,
|
|
body: encodedBody,
|
|
bodyIsBase64: bodyIsBase64,
|
|
responseType: this.responseType
|
|
});
|
|
}
|
|
|
|
abort() {
|
|
this._aborted = true;
|
|
safePostMessage({ __appXHR: true, action: 'abort', id: this._id });
|
|
}
|
|
|
|
_complete(payload) {
|
|
if (this._aborted && !payload.aborted) {
|
|
return;
|
|
}
|
|
|
|
this.status = payload.status || 0;
|
|
this.statusText = payload.statusText || '';
|
|
this.responseText = payload.responseText || '';
|
|
const base64 = payload.base64 || null;
|
|
|
|
if (this.responseType === 'json') {
|
|
try {
|
|
this.response = this.responseText ? JSON.parse(this.responseText) : null;
|
|
} catch (err) {
|
|
console.error('AppXMLHttpRequest: failed to parse JSON response', err);
|
|
this.response = null;
|
|
}
|
|
} else if (this.responseType === 'arraybuffer' && base64) {
|
|
this.response = base64ToArrayBuffer(base64);
|
|
} else {
|
|
this.response = this.responseText;
|
|
}
|
|
|
|
this.readyState = 4; // DONE
|
|
this._emitReadyState();
|
|
|
|
if (payload.aborted) {
|
|
if (typeof this.onabort === 'function') {
|
|
try { this.onabort(); } catch (err) { console.error(err); }
|
|
}
|
|
delete AppXMLHttpRequest.__pending[this._id];
|
|
return;
|
|
}
|
|
|
|
if (payload.error) {
|
|
if (typeof this.onerror === 'function') {
|
|
try { this.onerror(new Error(payload.error)); } catch (err) { console.error(err); }
|
|
}
|
|
} else if (typeof this.onload === 'function') {
|
|
try { this.onload(); } catch (err) { console.error(err); }
|
|
}
|
|
|
|
delete AppXMLHttpRequest.__pending[this._id];
|
|
}
|
|
|
|
_emitReadyState() {
|
|
if (typeof this.onreadystatechange === 'function') {
|
|
try {
|
|
this.onreadystatechange();
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
}
|
|
}
|
|
|
|
static __nextId() {
|
|
AppXMLHttpRequest.__counter += 1;
|
|
return `app-xhr-${AppXMLHttpRequest.__counter}`;
|
|
}
|
|
|
|
static __receive(payload) {
|
|
const instance = AppXMLHttpRequest.__pending[payload.id];
|
|
if (instance) {
|
|
instance._complete(payload);
|
|
}
|
|
}
|
|
}
|
|
|
|
AppXMLHttpRequest.__counter = 0;
|
|
AppXMLHttpRequest.__pending = {};
|
|
|
|
window.__XMLHttpRequest = window.XMLHttpRequest;
|
|
window.XMLHttpRequest = AppXMLHttpRequest; // alias with requested casing
|
|
})();
|
|
"""
|
|
|
|
static let backgroundBridgeScript = """
|
|
var miniTool = this.miniTool || {};
|
|
miniTool.__handler = null;
|
|
miniTool.onMessage = function(handler) { miniTool.__handler = handler; };
|
|
miniTool.postMessage = function(payload) { __miniToolPostMessage(payload); };
|
|
miniTool.log = function(log) { __miniToolLog(log); }
|
|
function __miniToolReceive(payload) {
|
|
try {
|
|
if (typeof miniTool.__handler === 'function') {
|
|
miniTool.__handler(payload);
|
|
}
|
|
} catch (err) {
|
|
console.log(err);
|
|
}
|
|
}
|
|
this.miniTool = miniTool;
|
|
"""
|
|
|
|
static func encodePayload(_ payload: Any) -> String? {
|
|
if JSONSerialization.isValidJSONObject(payload) {
|
|
if let data = try? JSONSerialization.data(withJSONObject: payload, options: []),
|
|
let string = String(data: data, encoding: .utf8) {
|
|
return string
|
|
}
|
|
}
|
|
if let string = payload as? String {
|
|
let escaped = string.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"")
|
|
return "\"\(escaped)\""
|
|
}
|
|
if let number = payload as? NSNumber {
|
|
return number.stringValue
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - App-backed XHR
|
|
|
|
private extension MiniToolRuntime {
|
|
func handleAppXHRMessage(_ payload: [String: Any]) {
|
|
guard let capabilities = toolInfo.capabilities, capabilities.internetAccess ?? false else {
|
|
appendLog("Internet access is disabled! To enable it, enable internetAccess in toolInfo.json")
|
|
return
|
|
}
|
|
|
|
guard let id = payload["id"] as? String else {
|
|
appendLog("AppXHR missing id")
|
|
return
|
|
}
|
|
|
|
let action = payload["action"] as? String ?? "request"
|
|
|
|
if action == "abort" {
|
|
if let task = appXHRTasks[id] {
|
|
task.cancel()
|
|
appXHRTasks[id] = nil
|
|
}
|
|
deliverAppXHRResponse(["id": id, "status": 0, "statusText": "aborted", "aborted": true])
|
|
return
|
|
}
|
|
|
|
guard let urlString = payload["url"] as? String, let url = URL(string: urlString) else {
|
|
appendLog("AppXHR invalid URL for id \(id)")
|
|
deliverAppXHRResponse(["id": id, "status": 0, "statusText": "invalid-url", "error": "Invalid URL"])
|
|
return
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = (payload["method"] as? String) ?? "GET"
|
|
|
|
if let headers = payload["headers"] as? [String: Any] {
|
|
headers.forEach { key, value in
|
|
if let valueString = value as? String ?? (value as? NSNumber)?.stringValue {
|
|
request.setValue(valueString, forHTTPHeaderField: key)
|
|
}
|
|
}
|
|
}
|
|
|
|
if let body = payload["body"] {
|
|
let isBase64 = payload["bodyIsBase64"] as? Bool ?? false
|
|
if let stringBody = body as? String {
|
|
if isBase64, let data = Data(base64Encoded: stringBody) {
|
|
request.httpBody = data
|
|
} else {
|
|
request.httpBody = stringBody.data(using: .utf8)
|
|
}
|
|
} else if JSONSerialization.isValidJSONObject(body),
|
|
let data = try? JSONSerialization.data(withJSONObject: body, options: []) {
|
|
request.httpBody = data
|
|
}
|
|
}
|
|
|
|
let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
|
|
guard let self else { return }
|
|
|
|
var responsePayload: [String: Any] = [
|
|
"id": id,
|
|
"status": 0,
|
|
"statusText": ""
|
|
]
|
|
|
|
if let http = response as? HTTPURLResponse {
|
|
responsePayload["status"] = http.statusCode
|
|
responsePayload["statusText"] = HTTPURLResponse.localizedString(forStatusCode: http.statusCode)
|
|
|
|
let headers = http.allHeaderFields.reduce(into: [String: String]()) { partialResult, entry in
|
|
if let key = entry.key as? String {
|
|
partialResult[key] = String(describing: entry.value)
|
|
}
|
|
}
|
|
responsePayload["headers"] = headers
|
|
}
|
|
|
|
if let error = error as NSError? {
|
|
if error.code == NSURLErrorCancelled {
|
|
responsePayload["aborted"] = true
|
|
responsePayload["statusText"] = "aborted"
|
|
} else {
|
|
responsePayload["error"] = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
if let data = data {
|
|
responsePayload["responseText"] = String(data: data, encoding: .utf8) ?? ""
|
|
responsePayload["base64"] = data.base64EncodedString()
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
self.appXHRTasks[id] = nil
|
|
self.deliverAppXHRResponse(responsePayload)
|
|
}
|
|
}
|
|
|
|
appXHRTasks[id] = task
|
|
task.resume()
|
|
}
|
|
|
|
func deliverAppXHRResponse(_ payload: [String: Any]) {
|
|
guard let json = MiniToolRuntime.encodePayload(payload) else {
|
|
appendLog("AppXHR: Unable to encode response for id \(payload["id"] ?? "<unknown>")")
|
|
return
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
let script = "window.XMLHttpRequest.__receive(\(json))"
|
|
self.webView!.evaluateJavaScript(script) { _, error in
|
|
if let error {
|
|
self.appendLog("AppXHR deliver error: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
class LeakAvoider : NSObject, WKURLSchemeHandler, WKScriptMessageHandler, WKDownloadDelegate {
|
|
func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String) async -> URL? {
|
|
return await self.delegate?.download(download, decideDestinationUsing: response, suggestedFilename: suggestedFilename)
|
|
}
|
|
|
|
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
self.delegate?.userContentController(userContentController, didReceive: message)
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
|
|
self.delegate?.webView(webView, start: urlSchemeTask)
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
|
|
self.delegate?.webView(webView, stop: urlSchemeTask)
|
|
}
|
|
|
|
|
|
|
|
weak var delegate : (WKURLSchemeHandler & WKScriptMessageHandler & WKDownloadDelegate)?
|
|
init (delegate:WKURLSchemeHandler & WKScriptMessageHandler & WKDownloadDelegate) {
|
|
self.delegate = delegate
|
|
super.init()
|
|
}
|
|
|
|
}
|