mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-16 04:20:30 +00:00
* Heartbeat rework * remove some debug loggings * stik mini tool poc * Saperate JITEnableContext into extensions and put each feature into their own files. Fix local pairing file is removed after switched to external device by directly loading the remote device's pairing file in place. * custom origin & xmlhttprequest * Fix some memory leak * allow background script access idevice (codes are from the old JSSupport branch) Tests are not carried out but most of the js api should work. Do we need to obfuscate some functions' name? * Fix JITEnableContext singleton initializer race condition * Fix instprox callback --------- Co-authored-by: Stephen B <158498287+StephenDev0@users.noreply.github.com>
599 lines
22 KiB
Swift
599 lines
22 KiB
Swift
import Foundation
|
|
import UniformTypeIdentifiers
|
|
import SwiftUI
|
|
import WebKit
|
|
import JavaScriptCore
|
|
|
|
final class MiniToolRuntime: NSObject, ObservableObject {
|
|
let tool: MiniToolBundle
|
|
@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) {
|
|
self.tool = tool
|
|
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)
|
|
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)
|
|
|
|
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, 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 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()
|
|
}
|
|
|
|
}
|