Show stiktool capabilities before first run. Fix instproxy get apps issue.

This commit is contained in:
Huge_Black 2025-12-20 14:08:47 +08:00
parent 44d81afa4e
commit 77b8810e6b
7 changed files with 343 additions and 72 deletions

View file

@ -12,21 +12,34 @@
@end
NSDictionary *dictionaryFromPlistData(NSData *plistData, NSError **error) {
if (!plistData) {
NSDictionary *dictionaryFromPlist(plist_t plist, NSError **error) {
if (!plist) {
if (error) {
*error = [NSError errorWithDomain:@"PlistConversionErrorDomain"
code:1001
userInfo:@{NSLocalizedDescriptionKey: @"Input plist data is nil."}];
userInfo:@{NSLocalizedDescriptionKey: @"Input plist is nil."}];
}
return nil;
}
char* buf = 0;
uint32_t plistlen = 0;
// try bin plist first, if failed, we try xml plist
plist_to_bin(plist, &buf, &plistlen);
NSPropertyListFormat format;
NSDictionary *result = [NSPropertyListSerialization propertyListWithData:plistData
options:NSPropertyListImmutable
format:&format
error:error];
NSDictionary *result = [NSPropertyListSerialization propertyListWithData:[NSData dataWithBytes:buf length:plistlen]
options:NSPropertyListImmutable format:&format error:error];
plist_mem_free(buf);
if (![result isKindOfClass:[NSDictionary class]]) {
plist_to_xml(plist, &buf, &plistlen);
if (error) {
*error = nil;
}
result = [NSPropertyListSerialization propertyListWithData:[NSData dataWithBytes:buf length:plistlen]
options:NSPropertyListImmutable format:&format error:error];
plist_free(buf);
}
if (![result isKindOfClass:[NSDictionary class]]) {
if (error && !*error) {
*error = [NSError errorWithDomain:@"PlistConversionErrorDomain"
@ -78,11 +91,12 @@ const char** cstrArrFromNSArray(NSArray* arr, int* validCount) {
int maxDataId;
}
- (instancetype)initWithContext:(JSContext*)context {
- (instancetype)initWithContext:(JSContext*)context allowedFunctions:(NSArray<NSString*>*)allowedFunctions {
maxHandleId = 0;
maxDataId = 0;
handles = [[NSMutableDictionary alloc] init];
dataPool = [[NSMutableDictionary alloc] init];
self->allowedFunctions = allowedFunctions;
self->context = context;
return self;
}
@ -150,6 +164,11 @@ const char** cstrArrFromNSArray(NSArray* arr, int* validCount) {
return;
}
if(![self->allowedFunctions containsObject:message[@"command"]]) {
[rejectFunc callWithArguments:@[[NSString stringWithFormat:@"calling %@ is not allowed! Declare it in toolInfo.json", message[@"command"]]]];
return;
}
NSString* handlerSelectorStr = [NSString stringWithFormat:@"%@WithBody:replyHandler:", message[@"command"]];
if(![self respondsToSelector:NSSelectorFromString(handlerSelectorStr)]) {
[rejectFunc callWithArguments:@[@"Invalid idevice function!"]];

View file

@ -69,23 +69,24 @@ void installationProxyCallback(uint64_t progress, struct InstallationProxyCallba
}
plist_t *app_list = (plist_t *)apps;
NSMutableArray* ans = [[NSMutableArray alloc] init];
for(int i = 0; i < apps_len; ++i) {
char* buf = 0;
uint32_t plistlen = 0;
plist_to_bin(app_list[i], &buf, &plistlen);
NSError* err2 = 0;
NSDictionary* appDict = dictionaryFromPlistData([NSData dataWithBytes:buf length:plistlen], &err2);
plist_mem_free(buf);
if(err2) {
replyHandler(nil, @"failed to parse plist data");
return;
do {
NSMutableArray* ans = [[NSMutableArray alloc] init];
for(int i = 0; i < apps_len; ++i) {
NSError* err2 = 0;
NSDictionary* appDict = dictionaryFromPlist(app_list[i], &err2);
if(err2) {
replyHandler(nil, @"failed to parse plist data");
break;
}
[ans addObject:appDict];
}
[ans addObject:appDict];
replyHandler(ans, nil);
} while(0);
for(int i = 0; i < apps_len; ++i) {
plist_free(app_list[i]);
}
replyHandler(ans, nil);
}
- (void)installation_proxy_installWithBody:(NSDictionary *)body replyHandler:(nonnull void (^)(id _Nullable, NSString * _Nullable))replyHandler {
@ -266,33 +267,33 @@ void installationProxyCallback(uint64_t progress, struct InstallationProxyCallba
plist_from_memory((void*)[optionsNSData bytes], (uint32_t)[optionsNSData length], &optionsPlist, 0);
}
plist_t *apps = NULL;
plist_t *app_list = NULL;
size_t apps_len = 0;
IdeviceFfiError* err = installation_proxy_browse(client, optionsPlist, &apps, &apps_len);
IdeviceFfiError* err = installation_proxy_browse(client, optionsPlist, &app_list, &apps_len);
if (err) {
replyHandler(nil, [self errFreeFromIdeviceFfiError:err]);
return;
}
NSMutableArray* ans = [[NSMutableArray alloc] init];
do {
NSMutableArray* ans = [[NSMutableArray alloc] init];
for(int i = 0; i < apps_len; ++i) {
NSError* err2 = 0;
NSDictionary* appDict = dictionaryFromPlist(app_list[i], &err2);
if(err2) {
replyHandler(nil, @"failed to parse plist data");
break;
}
[ans addObject:appDict];
}
replyHandler(ans, nil);
} while(0);
for(int i = 0; i < apps_len; ++i) {
char* buf = 0;
uint32_t plistlen = 0;
plist_to_bin(apps[i], &buf, &plistlen);
NSError* err2 = 0;
NSDictionary* appDict = dictionaryFromPlistData([NSData dataWithBytes:buf length:plistlen], &err2);
plist_mem_free(buf);
if(err2) {
replyHandler(nil, @"failed to parse plist data");
return;
}
[ans addObject:appDict];
if([ans count] >= 100) {
break;
}
plist_free(app_list[i]);
}
replyHandler(ans, nil);
}

View file

@ -23,9 +23,10 @@
@interface IDeviceJSBridge : NSObject {
NSMutableDictionary<NSNumber*, IDeviceHandle*>* handles;
NSMutableDictionary<NSNumber*, NSData*>* dataPool;
NSArray<NSString*>* allowedFunctions;
JSContext* context;
}
- (instancetype)initWithContext:(JSContext*)context;
- (instancetype)initWithContext:(JSContext*)context allowedFunctions:(NSArray<NSString*>*)allowedFunctions;
- (void)didReceiveScriptMessage:(NSDictionary *)message resolve:(JSValue*)resolveFunc reject:(JSValue*)rejectFunc;
- (void)cleanUp;
- (NSString*)errFreeFromIdeviceFfiError:(IdeviceFfiError*)err;
@ -36,6 +37,6 @@
@end
NSDictionary *dictionaryFromPlistData(NSData *plistData, NSError **error);
NSDictionary *dictionaryFromPlist(plist_t plist, NSError **error);
NSData *plistDataFromDictionary(NSDictionary *dictionary, NSError **error);
const char** cstrArrFromNSArray(NSArray* arr, int* validCount);

View file

@ -2,6 +2,28 @@ import SwiftUI
import UniformTypeIdentifiers
import UIKit
class ToolCombo : Hashable, ObservableObject {
static func == (lhs: ToolCombo, rhs: ToolCombo) -> Bool {
return lhs === rhs
}
func hash(into hasher: inout Hasher) {
hasher.combine(ObjectIdentifier(self))
}
init(tool: MiniToolBundle, info: ToolInfo) {
self.tool = tool
self.info = info
}
init() {
self.tool = nil
self.info = nil
}
@Published var tool: MiniToolBundle?
@Published var info: ToolInfo?
}
struct MiniToolListView: View {
@StateObject private var store = MiniToolStore()
@State private var searchText = ""
@ -11,6 +33,9 @@ struct MiniToolListView: View {
@State private var alertVisible = false
@State private var alertTitle = ""
@State private var alertMessage = ""
@State private var showInfoSheet = false
@State private var navigationToolCombo: ToolCombo?
@StateObject private var pendingToolCombo: ToolCombo = ToolCombo()
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
@Environment(\.themeExpansionManager) private var themeExpansion
@Environment(\.colorScheme) private var colorScheme
@ -102,8 +127,14 @@ struct MiniToolListView: View {
presentError(title: "Import Failed", message: error.localizedDescription)
}
}
.navigationDestination(item: $navigationToolCombo) { combo in
MiniToolRunnerView(tool: combo.tool!, toolInfo: combo.info!)
}
}
.preferredColorScheme(preferredScheme)
.sheet(isPresented: $showInfoSheet) {
toolInfoSheet
}
}
// MARK: - Cards
@ -130,8 +161,8 @@ struct MiniToolListView: View {
private func toolRow(_ tool: MiniToolBundle) -> some View {
HStack(spacing: 12) {
NavigationLink {
MiniToolRunnerView(tool: tool)
Button {
handleRun(tool)
} label: {
HStack(spacing: 12) {
Image(systemName: "shippingbox.fill")
@ -146,29 +177,28 @@ struct MiniToolListView: View {
.foregroundColor(.secondary)
.lineLimit(1)
}
Spacer()
}
Spacer()
NavigationLink {
MiniToolEditorView(tool: tool)
} label: {
Image(systemName: "pencil")
.foregroundColor(.primary)
}
.buttonStyle(.borderless)
Button(role: .destructive) {
pendingDelete = tool
showDeleteConfirmation = true
} label: {
Image(systemName: "trash")
.foregroundColor(.red)
}
.buttonStyle(.borderless)
}
.buttonStyle(.plain)
NavigationLink {
MiniToolEditorView(tool: tool)
} label: {
Image(systemName: "pencil")
.foregroundColor(.primary)
}
.buttonStyle(.borderless)
Button(role: .destructive) {
pendingDelete = tool
showDeleteConfirmation = true
} label: {
Image(systemName: "trash")
.foregroundColor(.red)
}
.buttonStyle(.borderless)
}
.padding(20)
.background(glassyBackground)
@ -253,4 +283,158 @@ struct MiniToolListView: View {
private func copy(_ text: String) {
UIPasteboard.general.string = text
}
private func handleRun(_ tool: MiniToolBundle) {
do {
guard let info = try loadToolInfo(for: tool) else {
presentError(title: "Mini Tool", message: "toolInfo.json is missing or unreadable for this tool.")
return
}
if try isTrusted(tool) {
navigationToolCombo = ToolCombo(tool: tool, info: info)
return
}
pendingToolCombo.info = info
pendingToolCombo.tool = tool
print("set pendingToolInfo")
showInfoSheet = true
print("pendingToolInfo = true")
} catch {
presentError(title: "Mini Tool", message: error.localizedDescription)
}
}
private func loadToolInfo(for tool: MiniToolBundle) throws -> ToolInfo? {
let infoURL = tool.url.appendingPathComponent("toolInfo.json")
guard FileManager.default.fileExists(atPath: infoURL.path) else { return nil }
let data = try Data(contentsOf: infoURL)
return try JSONDecoder().decode(ToolInfo.self, from: data)
}
private func isTrusted(_ tool: MiniToolBundle) throws -> Bool {
guard let uuid = UIDevice.current.identifierForVendor?.uuidString else { return false }
let checkURL = tool.url.appendingPathComponent("check")
guard FileManager.default.fileExists(atPath: checkURL.path) else { return false }
let contents = try String(contentsOf: checkURL, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines)
return contents == uuid
}
private func authorizeAndRun() {
guard let tool = pendingToolCombo.tool else {
return
}
do {
try writeCheck(for: tool)
navigationToolCombo = pendingToolCombo
} catch {
presentError(title: "Mini Tool", message: error.localizedDescription)
}
resetPending()
}
private func writeCheck(for tool: MiniToolBundle) throws {
guard let uuid = UIDevice.current.identifierForVendor?.uuidString else {
throw NSError(domain: "MiniTool", code: -1, userInfo: [NSLocalizedDescriptionKey: "identifierForVendor is unavailable."])
}
let checkURL = tool.url.appendingPathComponent("check")
try uuid.write(to: checkURL, atomically: true, encoding: .utf8)
}
private func resetPending() {
showInfoSheet = false
// pendingTool = MiniToolBundle()
}
@ViewBuilder
private var toolInfoSheet: some View {
if let tool = pendingToolCombo.tool, let info = pendingToolCombo.info {
ToolInfoSheet(tool: tool, info: info) {
authorizeAndRun()
} onCancel: {
resetPending()
}
}
}
}
private struct ToolInfoSheet: View {
let tool: MiniToolBundle
let info: ToolInfo
let onAllow: () -> Void
let onCancel: () -> Void
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text(tool.name)
.font(.title2.weight(.semibold))
Text(info.desc)
.font(.body)
.foregroundColor(.secondary)
}
VStack(alignment: .leading, spacing: 8) {
Label("Author", systemImage: "person")
.font(.subheadline.weight(.semibold))
Text(info.author)
.font(.body)
}
if let capabilities = info.capabilities {
VStack(alignment: .leading, spacing: 8) {
Label("Capabilities", systemImage: "checkmark.shield")
.font(.subheadline.weight(.semibold))
CapabilityRow(title: "Internet Access", enabled: capabilities.internetAccess ?? false)
}
}
if let functions = info.requiredIDeviceFunctions, !functions.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Label("Required iDevice Functions", systemImage: "bolt.horizontal.circle")
.font(.subheadline.weight(.semibold))
ForEach(functions, id: \.self) { function in
HStack(spacing: 8) {
Image(systemName: "bolt.fill")
.foregroundColor(.blue)
Text(function)
.font(.body)
}
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(20)
}
.navigationTitle("Allow Tool?")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { onCancel() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Allow & Run") { onAllow() }
.bold()
}
}
}
}
}
private struct CapabilityRow: View {
let title: String
let enabled: Bool
var body: some View {
HStack(spacing: 8) {
Image(systemName: enabled ? "checkmark.circle.fill" : "xmark.circle")
.foregroundColor(enabled ? .green : .red)
Text(title)
.font(.body)
}
}
}

View file

@ -1,8 +1,41 @@
import Foundation
struct MiniToolBundle: Identifiable, Hashable {
let url: URL
struct ToolInfo: Decodable {
struct Capabilities: Decodable {
let internetAccess: Bool?
enum CodingKeys: String, CodingKey {
case internetAccess = "internet_access"
}
}
let author: String
let desc: String
let capabilities: Capabilities?
let requiredIDeviceFunctions: [String]?
enum CodingKeys: String, CodingKey {
case author
case desc
case capabilities
case requiredIDeviceFunctions = "required_idevice_functions"
}
}
class MiniToolBundle: ObservableObject, Identifiable {
static func == (lhs: MiniToolBundle, rhs: MiniToolBundle) -> Bool {
return lhs.name == rhs.name
}
@Published var url: URL
init(url: URL) {
self.url = url
}
var id: String { url.lastPathComponent }
var name: String { url.deletingPathExtension().lastPathComponent }
var indexURL: URL { url.appendingPathComponent("index.html") }

View file

@ -3,6 +3,7 @@ import WebKit
struct MiniToolRunnerView: View {
let tool: MiniToolBundle
let toolInfo: ToolInfo
@StateObject private var runtime: MiniToolRuntime
@State private var showLogs = false
@State private var initiated = false
@ -13,9 +14,10 @@ struct MiniToolRunnerView: View {
private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) }
init(tool: MiniToolBundle) {
init(tool: MiniToolBundle, toolInfo: ToolInfo) {
self.tool = tool
_runtime = StateObject(wrappedValue: MiniToolRuntime(tool: tool))
self.toolInfo = toolInfo
_runtime = StateObject(wrappedValue: MiniToolRuntime(tool: tool, toolInfo: toolInfo))
}
var body: some View {

View file

@ -6,6 +6,7 @@ import JavaScriptCore
final class MiniToolRuntime: NSObject, ObservableObject {
let tool: MiniToolBundle
let toolInfo: ToolInfo
@Published var logs: [String] = []
@Published var isReady: Bool = false
@ -18,8 +19,9 @@ final class MiniToolRuntime: NSObject, ObservableObject {
private var ideviceJSBridge : IDeviceJSBridge? = nil
init(tool: MiniToolBundle) {
init(tool: MiniToolBundle, toolInfo: ToolInfo) {
self.tool = tool
self.toolInfo = toolInfo
super.init()
let configuration = WKWebViewConfiguration()
let controller = WKUserContentController()
@ -64,7 +66,7 @@ final class MiniToolRuntime: NSObject, ObservableObject {
private func loadBackground() {
context = JSContext()
ideviceJSBridge = IDeviceJSBridge(context: context)
ideviceJSBridge = IDeviceJSBridge(context: context, allowedFunctions: toolInfo.requiredIDeviceFunctions ?? [])
context?.exceptionHandler = { [weak self] _, exception in
if let message = exception?.toString() {
self?.appendLog("Background exception: \(message)")
@ -155,8 +157,18 @@ extension MiniToolRuntime : WKURLSchemeHandler {
let path = url.path.isEmpty ? "index.html" : url.path
let fileURL = tool.url
.appendingPathComponent(path)
.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
@ -206,6 +218,20 @@ extension MiniToolRuntime: WKScriptMessageHandler {
// 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])
@ -462,6 +488,11 @@ extension MiniToolRuntime {
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