diff --git a/StikDebug.xcodeproj/project.pbxproj b/StikDebug.xcodeproj/project.pbxproj index 6982de1..7431b15 100644 --- a/StikDebug.xcodeproj/project.pbxproj +++ b/StikDebug.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ 17C744F02E20BED000834F17 /* Pipify in Frameworks */ = {isa = PBXBuildFile; productRef = 17C744EF2E20BED000834F17 /* Pipify */; }; - 687843662E8765D1002D0813 /* ZSignApple in Frameworks */ = {isa = PBXBuildFile; productRef = 687843652E8765D1002D0813 /* ZSignApple */; }; 68D1FA402E847E4A0028A0EA /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 68D1FA3F2E847E4A0028A0EA /* StoreKit.framework */; }; 68D569BE2E1B415700A5BA36 /* CodeEditorView in Frameworks */ = {isa = PBXBuildFile; productRef = 68D569BD2E1B415700A5BA36 /* CodeEditorView */; }; 68D569C02E1B415700A5BA36 /* LanguageSupport in Frameworks */ = {isa = PBXBuildFile; productRef = 68D569BF2E1B415700A5BA36 /* LanguageSupport */; }; @@ -161,7 +160,6 @@ 68D1FA402E847E4A0028A0EA /* StoreKit.framework in Frameworks */, 68D569BE2E1B415700A5BA36 /* CodeEditorView in Frameworks */, 68E714E62E6AA2B00025610F /* ZIPFoundation in Frameworks */, - 687843662E8765D1002D0813 /* ZSignApple in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -277,7 +275,6 @@ 17C744EF2E20BED000834F17 /* Pipify */, DCBA85852E3897BD00E88C06 /* StikImporter */, 68E714E52E6AA2B00025610F /* ZIPFoundation */, - 687843652E8765D1002D0813 /* ZSignApple */, ); productName = StikJIT; productReference = DC6F1D372D94EADD0071B2B6 /* StikDebug.app */; @@ -396,7 +393,6 @@ 17C744EE2E20BED000834F17 /* XCRemoteSwiftPackageReference "swiftui-pipify" */, DCBA85842E3897BD00E88C06 /* XCRemoteSwiftPackageReference "StikImporter" */, 68E714E42E6AA2B00025610F /* XCRemoteSwiftPackageReference "ZIPFoundation" */, - 687843642E8765D0002D0813 /* XCRemoteSwiftPackageReference "zsign-swift" */, ); preferredProjectObjectVersion = 77; productRefGroup = DC6F1D382D94EADD0071B2B6 /* Products */; @@ -690,8 +686,9 @@ DC6F1D5D2D94EADF0071B2B6 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = StikDebug; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = StikJIT/StikJIT.entitlements; CODE_SIGN_STYLE = Automatic; @@ -743,8 +740,9 @@ DC6F1D5E2D94EADF0071B2B6 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = StikDebug; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = StikJIT/StikJIT.entitlements; CODE_SIGN_STYLE = Automatic; @@ -1007,14 +1005,6 @@ kind = branch; }; }; - 687843642E8765D0002D0813 /* XCRemoteSwiftPackageReference "zsign-swift" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/loyahdev/zsign-swift"; - requirement = { - branch = main; - kind = branch; - }; - }; 68D569BC2E1B415700A5BA36 /* XCRemoteSwiftPackageReference "CodeEditorView" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/mchakravarty/CodeEditorView"; @@ -1047,11 +1037,6 @@ package = 17C744EE2E20BED000834F17 /* XCRemoteSwiftPackageReference "swiftui-pipify" */; productName = Pipify; }; - 687843652E8765D1002D0813 /* ZSignApple */ = { - isa = XCSwiftPackageProductDependency; - package = 687843642E8765D0002D0813 /* XCRemoteSwiftPackageReference "zsign-swift" */; - productName = ZSignApple; - }; 68D569BD2E1B415700A5BA36 /* CodeEditorView */ = { isa = XCSwiftPackageProductDependency; package = 68D569BC2E1B415700A5BA36 /* XCRemoteSwiftPackageReference "CodeEditorView" */; diff --git a/StikDebug.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/StikDebug.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4c7b105..7da08b0 100644 --- a/StikDebug.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/StikDebug.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "af465bb1c1430dc380451fa591e9921f0137b228028823dc223d9644e144d632", + "originHash" : "ac6355f0d394d08e98cd8bc09669552151ca717b83a414b5708b301c9ba767e4", "pins" : [ { "identity" : "codeeditorview", @@ -45,15 +45,6 @@ "revision" : "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0", "version" : "0.9.19" } - }, - { - "identity" : "zsign-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/loyahdev/zsign-swift", - "state" : { - "branch" : "main", - "revision" : "574ad0bc993ecb2b13b170730bd5b196005034b4" - } } ], "version" : 3 diff --git a/StikJIT/Assets.xcassets/AccentColor.colorset/Contents.json b/StikJIT/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index eb87897..0000000 --- a/StikJIT/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/StikJIT/Assets.xcassets/AppIcon.appiconset/Contents.json b/StikJIT/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 5d7d7eb..0000000 --- a/StikJIT/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "images" : [ - { - "filename" : "StikJIT.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "filename" : "StikJIT_Dark.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "tinted" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/StikJIT/Assets.xcassets/AppIcon.appiconset/StikJIT.png b/StikJIT/Assets.xcassets/AppIcon.appiconset/StikJIT.png deleted file mode 100644 index 11e4929..0000000 Binary files a/StikJIT/Assets.xcassets/AppIcon.appiconset/StikJIT.png and /dev/null differ diff --git a/StikJIT/Assets.xcassets/AppIcon.appiconset/StikJIT_Dark.png b/StikJIT/Assets.xcassets/AppIcon.appiconset/StikJIT_Dark.png deleted file mode 100644 index 6250814..0000000 Binary files a/StikJIT/Assets.xcassets/AppIcon.appiconset/StikJIT_Dark.png and /dev/null differ diff --git a/StikJIT/Assets.xcassets/DarkUI.imageset/Contents.json b/StikJIT/Assets.xcassets/DarkUI.imageset/Contents.json deleted file mode 100644 index c4b1af8..0000000 --- a/StikJIT/Assets.xcassets/DarkUI.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "DarkUI.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/StikJIT/Assets.xcassets/DarkUI.imageset/DarkUI.png b/StikJIT/Assets.xcassets/DarkUI.imageset/DarkUI.png deleted file mode 100644 index 64ffdc7..0000000 Binary files a/StikJIT/Assets.xcassets/DarkUI.imageset/DarkUI.png and /dev/null differ diff --git a/StikJIT/Assets.xcassets/LightUI.imageset/Contents.json b/StikJIT/Assets.xcassets/LightUI.imageset/Contents.json deleted file mode 100644 index fa042e1..0000000 --- a/StikJIT/Assets.xcassets/LightUI.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "LightUI.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/StikJIT/Assets.xcassets/LightUI.imageset/LightUI.png b/StikJIT/Assets.xcassets/LightUI.imageset/LightUI.png deleted file mode 100644 index bca5c70..0000000 Binary files a/StikJIT/Assets.xcassets/LightUI.imageset/LightUI.png and /dev/null differ diff --git a/StikJIT/Assets.xcassets/StikJIT.imageset/Contents.json b/StikJIT/Assets.xcassets/StikDebug.imageset/Contents.json similarity index 80% rename from StikJIT/Assets.xcassets/StikJIT.imageset/Contents.json rename to StikJIT/Assets.xcassets/StikDebug.imageset/Contents.json index 7a8f427..1da6004 100644 --- a/StikJIT/Assets.xcassets/StikJIT.imageset/Contents.json +++ b/StikJIT/Assets.xcassets/StikDebug.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "StikJIT.png", + "filename" : "StikDebug New-iOS-Default-1024x1024@1x.png", "idiom" : "universal", "scale" : "1x" }, diff --git a/StikJIT/Assets.xcassets/StikDebug.imageset/StikDebug New-iOS-Default-1024x1024@1x.png b/StikJIT/Assets.xcassets/StikDebug.imageset/StikDebug New-iOS-Default-1024x1024@1x.png new file mode 100644 index 0000000..e0bfa9f Binary files /dev/null and b/StikJIT/Assets.xcassets/StikDebug.imageset/StikDebug New-iOS-Default-1024x1024@1x.png differ diff --git a/StikJIT/Assets.xcassets/StikJIT.imageset/StikJIT.png b/StikJIT/Assets.xcassets/StikJIT.imageset/StikJIT.png deleted file mode 100644 index 11e4929..0000000 Binary files a/StikJIT/Assets.xcassets/StikJIT.imageset/StikJIT.png and /dev/null differ diff --git a/StikJIT/Assets.xcassets/System.imageset/Contents.json b/StikJIT/Assets.xcassets/System.imageset/Contents.json deleted file mode 100644 index dc33a59..0000000 --- a/StikJIT/Assets.xcassets/System.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "System.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/StikJIT/Assets.xcassets/System.imageset/System.png b/StikJIT/Assets.xcassets/System.imageset/System.png deleted file mode 100644 index 5d07c96..0000000 Binary files a/StikJIT/Assets.xcassets/System.imageset/System.png and /dev/null differ diff --git a/StikJIT/Geode.js b/StikJIT/Geode.js new file mode 100644 index 0000000..71cdb8c --- /dev/null +++ b/StikJIT/Geode.js @@ -0,0 +1,158 @@ +function littleEndianHexStringToNumber(hexStr) { + const bytes = []; + for (let i = 0; i < hexStr.length; i += 2) { + bytes.push(parseInt(hexStr.substr(i, 2), 16)); + } + let num = 0n; + for (let i = 4; i >= 0; i--) { + num = (num << 8n) | BigInt(bytes[i]); + } + return num; +} + +function numberToLittleEndianHexString(num) { + const bytes = []; + for (let i = 0; i < 5; i++) { + bytes.push(Number(num & 0xFFn)); + num >>= 8n; + } + while (bytes.length < 8) { + bytes.push(0); + } + return bytes.map(b => b.toString(16).padStart(2, '0')).join(''); +} + +function littleEndianHexToU32(hexStr) { + return parseInt(hexStr.match(/../g).reverse().join(''), 16); +} + +function extractBrkImmediate(u32) { + return (u32 >> 5) & 0xFFFF; +} + +let pid = get_pid(); +log(`pid = ${pid}`); +let attachResponse = send_command(`vAttach;${pid.toString(16)}`); +log(`attach_response = ${attachResponse}`); + +let validBreakpoints = 0; +let totalBreakpoints = 0; +let invalidBreakpoints = 0; + +while (invalidBreakpoints < 10) { + totalBreakpoints++; + log(`Handling breakpoint ${totalBreakpoints} (valid: ${validBreakpoints})`); + + let brkResponse = send_command(`c`); + log(`brkResponse = ${brkResponse}`); + + let tidMatch = /T[0-9a-f]+thread:(?[0-9a-f]+);/.exec(brkResponse); + let tid = tidMatch ? tidMatch.groups['tid'] : null; + let pcMatch = /20:(?[0-9a-f]{16});/.exec(brkResponse); + let pc = pcMatch ? pcMatch.groups['reg'] : null; + let x0Match = /00:(?[0-9a-f]{16});/.exec(brkResponse); + let x0 = x0Match ? x0Match.groups['reg'] : null; + let x1Match = /01:(?[0-9a-f]{16});/.exec(brkResponse); + let x1 = x1Match ? x1Match.groups['reg'] : null; + + if (!tid || !pc || !x0) { + log(`Failed to extract registers: tid=${tid}, pc=${pc}, x0=${x0}, x1=${x1}`); + invalidBreakpoints++; + continue; + } + + const pcNum = littleEndianHexStringToNumber(pc); + const x0Num = littleEndianHexStringToNumber(x0); + const x1Num = x1 ? littleEndianHexStringToNumber(x1) : 0n; + log(`tid = ${tid}, pc = ${pcNum.toString(16)}, x0 = ${x0Num.toString(16)}, x1 = ${x1Num.toString(16)}`); + + let instructionResponse = send_command(`m${pcNum.toString(16)},4`); + log(`instruction at pc: ${instructionResponse}`); + let instrU32 = littleEndianHexToU32(instructionResponse); + let brkImmediate = extractBrkImmediate(instrU32); + log(`BRK immediate: 0x${brkImmediate.toString(16)} (${brkImmediate})`); + + if (brkImmediate !== 0x69 && brkImmediate !== 0x70 && brkImmediate !== 0x71) { + log(`Skipping: BRK immediate not 0x69 or 0x70 or 0x71 (was 0x${brkImmediate.toString(16)})`); + invalidBreakpoints++; + continue; + } + invalidBreakpoints = 0; + + if (brkImmediate === 0x69) { // usual jit mapping + log(`Received command to process JIT mapping (0x69)`); + + let jitPageAddress = x0Num; + let size = x1Num > 0n ? x1Num : 0x10000n; // allocate 64 KB if x1 somehow is 0 + log(`Got RX page address: 0x${jitPageAddress.toString(16)}, preparing region with 0x${size.toString(16)} bytes!`); + + let prepareJITPageResponse = prepare_memory_region(Number(jitPageAddress), Number(size)); // Unsure if this is specific to iOS26 but this func doesnt take in a BigInt, resulting in an error unless converted to a number. I'm not sure how other scripts don't have this problem. + log(`prepareJITPageResponse = ${prepareJITPageResponse}`); + + let pcPlus4 = numberToLittleEndianHexString(pcNum + 4n); + let pcPlus4Response = send_command(`P20=${pcPlus4};thread:${tid};`); + log(`pcPlus4Response = ${pcPlus4Response}`); + validBreakpoints++; + } else if (brkImmediate === 0x70) { // patching instructs + log(`Received command to patch instructions (0x70)`); + + let x2Match = /02:(?[0-9a-f]{16});/.exec(brkResponse); + let x2 = x2Match ? x2Match.groups['reg'] : null; + + if (!x1 || !x2) { + log(`Missing x1 or x2 for function patching`); + continue; + } + + let destAddr = x0Num; + let srcAddr = x1Num; + let size = x2 ? littleEndianHexStringToNumber(x2) : 10n; + log(`Patching: dest=0x${destAddr.toString(16)}, src=0x${srcAddr.toString(16)}, size=0x${size.toString(16)}`); + + // Unsure exactly why, but anything over 4 MB freezes the app for some reason, so we will set a soft limit + if (size > 0x400000n) { + log(`Size too large (0x${size.toString(16)}), skipping`); + let pcPlus4 = numberToLittleEndianHexString(pcNum + 4n); + let pcPlus4Response = send_command(`P20=${pcPlus4};thread:${tid};`); + log(`pcPlus4Response = ${pcPlus4Response}`); + validBreakpoints++; + continue; + } + try { + // m (read) = `m${curPointer.toString(16)},` + // M (write) = `M${curPointer.toString(16)},:` + const CHUNK_SIZE = 0x4000n; // 16 KB + for (let i = 0n; i < size; i += CHUNK_SIZE) { + let chunkSize = i + CHUNK_SIZE <= size ? CHUNK_SIZE : size - i; + let readAddr = srcAddr + i; + let writeAddr = destAddr + i; + let readRes = send_command(`m${readAddr.toString(16)},${chunkSize.toString(16)}`); + if (readRes && readRes.length > 0) { + let writeResponse = send_command(`M${writeAddr.toString(16)},${chunkSize.toString(16)}:${readRes}`); + if (writeResponse !== "OK") { + log(`Write failed at offset ${i.toString(16)}`); + break; + } + } + if (Number(i / CHUNK_SIZE) % 10 === 0) { + log(`Progress: 0x${i.toString(16)}/0x${size.toString(16)}`); + } + } + log(`Memory write completed!`); + } catch (e) { + log(`Memory write failed: ${e}`); + } + + let pcPlus4 = numberToLittleEndianHexString(pcNum + 4n); + let pcPlus4Response = send_command(`P20=${pcPlus4};thread:${tid};`); + log(`pcPlus4Response = ${pcPlus4Response}`); + validBreakpoints++; + } else if (brkImmediate === 0x71) { // detach, might be unnecessary + break; + } + + log(`Completed breakpoint ${validBreakpoints}`); +} +log(`Stopping script (Received 0x71 or too many invalid breakpoints)`); +let detachResponse = send_command(`D`); +log(`detachResponse = ${detachResponse}`); diff --git a/StikJIT/StikDebug.icon/Assets/1Bolt.svg b/StikJIT/StikDebug.icon/Assets/1Bolt.svg new file mode 100644 index 0000000..99ba434 --- /dev/null +++ b/StikJIT/StikDebug.icon/Assets/1Bolt.svg @@ -0,0 +1,3 @@ + + + diff --git a/StikJIT/StikDebug.icon/Assets/2Grid.svg b/StikJIT/StikDebug.icon/Assets/2Grid.svg new file mode 100644 index 0000000..8721ed8 --- /dev/null +++ b/StikJIT/StikDebug.icon/Assets/2Grid.svg @@ -0,0 +1,3 @@ + + + diff --git a/StikJIT/StikDebug.icon/icon.json b/StikJIT/StikDebug.icon/icon.json new file mode 100644 index 0000000..a1893c7 --- /dev/null +++ b/StikJIT/StikDebug.icon/icon.json @@ -0,0 +1,52 @@ +{ + "fill" : { + "linear-gradient" : [ + "display-p3:0.38110,0.78809,0.97949,1.00000", + "display-p3:0.20813,0.42505,0.94629,1.00000" + ] + }, + "groups" : [ + { + "blur-material" : 0.5, + "layers" : [ + { + "image-name" : "1Bolt.svg", + "name" : "1Bolt" + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.7 + } + }, + { + "layers" : [ + { + "fill" : { + "solid" : "extended-gray:1.00000,0.20000" + }, + "image-name" : "2Grid.svg", + "name" : "2Grid" + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/StikJIT/Views/HomeView.swift b/StikJIT/Views/HomeView.swift index f26748b..c10529b 100644 --- a/StikJIT/Views/HomeView.swift +++ b/StikJIT/Views/HomeView.swift @@ -78,6 +78,12 @@ struct HomeView: View { private var ddiMounted: Bool { isMounted() } private var canConnectByApp: Bool { pairingFileExists && ddiMounted } + private var pairingFileLikelyInvalid: Bool { + (pairingFileExists || pairingFilePresentOnDisk) && + !isValidatingPairingFile && + !ddiMounted && + !heartbeatOK + } private let pairingFileURL = URL.documentsDirectory.appendingPathComponent("pairingFile.plist") @@ -248,6 +254,12 @@ struct HomeView: View { autoScriptData = data autoScriptName = "script2.js" } + } else if appName == "Geode" { + if let url = Bundle.main.url(forResource: "Geode", withExtension: "js"), + let data = try? Data(contentsOf: url) { + autoScriptData = data + autoScriptName = "script2.js" + } } else if appName == "MeloNX" { if let url = Bundle.main.url(forResource: "melo", withExtension: "js"), let data = try? Data(contentsOf: url) { @@ -397,7 +409,7 @@ struct HomeView: View { } .disabled(isProcessing || isValidatingPairingFile) - if pairingFileExists && enableAdvancedOptions { + if pairingFileExists && enableAdvancedOptions && !pairingFileLikelyInvalid { Button(action: { showPIDSheet = true }) { secondaryButtonLabel(icon: "number.circle", title: "Connect by PID") } @@ -428,7 +440,7 @@ struct HomeView: View { return .init( title: "Pairing file needs attention", subtitle: "We found a pairing file but couldn’t read it.", - icon: "doc.badge.exclamationmark", + icon: "exclamationmark.triangle.fill", tint: .yellow ) } @@ -439,6 +451,14 @@ struct HomeView: View { tint: .orange ) } + if pairingFileLikelyInvalid { + return .init( + title: "Pairing file may be invalid", + subtitle: "We couldn’t reach your device. Create a new pairing file on your trusted computer, then try again.", + icon: "exclamationmark.triangle.fill", + tint: .yellow + ) + } if !ddiMounted { return .init( title: "Mount the Developer Disk Image", @@ -473,7 +493,8 @@ struct HomeView: View { private var primaryActionTitle: String { if isValidatingPairingFile { return "Validating…" } - if !pairingFileExists { return pairingFilePresentOnDisk ? "Re-import Pairing File" : "Import Pairing File" } + if !pairingFileExists { return pairingFilePresentOnDisk ? "Import New Pairing File" : "Import Pairing File" } + if pairingFileLikelyInvalid { return "Import New Pairing File" } if !ddiMounted { return "Mount Developer Disk Image" } return "Connect by App" } @@ -481,6 +502,7 @@ struct HomeView: View { private var primaryActionIcon: String { if isValidatingPairingFile { return "hourglass" } if !pairingFileExists { return pairingFilePresentOnDisk ? "arrow.clockwise" : "doc.badge.plus" } + if pairingFileLikelyInvalid { return "arrow.clockwise" } if !ddiMounted { return "externaldrive" } return "cable.connector.horizontal" } @@ -497,6 +519,14 @@ struct HomeView: View { actionTitle: nil, action: nil ) + } else if pairingFileLikelyInvalid { + pairingItem = ChecklistItem( + title: "Pairing file", + subtitle: "We couldn’t reach your device. Create a new pairing file on your trusted computer.", + status: .attention, + actionTitle: "Import New", + action: { isShowingPairingFilePicker = true } + ) } else if pairingFileExists { pairingItem = ChecklistItem( title: "Pairing file", @@ -508,9 +538,9 @@ struct HomeView: View { } else if pairingFilePresentOnDisk { pairingItem = ChecklistItem( title: "Pairing file", - subtitle: "We couldn’t read the pairing file that’s on disk. Re-import it from your trusted computer.", + subtitle: "We couldn’t read the pairing file that’s on disk. Create a new pairing file on your trusted computer.", status: .attention, - actionTitle: "Re-import", + actionTitle: "Import New", action: { isShowingPairingFilePicker = true } ) } else { @@ -523,14 +553,23 @@ struct HomeView: View { ) } + let hideOtherIndicators = pairingFileLikelyInvalid || (pairingFilePresentOnDisk && !pairingFileExists && !isValidatingPairingFile) + if hideOtherIndicators { + return [pairingItem] + } + return [ pairingItem, ChecklistItem( title: "Developer Disk Image", - subtitle: ddiMounted ? "Mounted successfully." : "", + subtitle: ddiMounted + ? "Mounted successfully." + : (pairingFileLikelyInvalid + ? "The Developer Disk Image can’t mount because the pairing file looks invalid. Create a new pairing file." + : ""), status: ddiMounted ? .ready : .attention, - actionTitle: nil, - action: nil + actionTitle: pairingFileLikelyInvalid ? "Import New" : nil, + action: pairingFileLikelyInvalid ? { isShowingPairingFilePicker = true } : nil ), ChecklistItem( title: "VPN tunnel", @@ -910,6 +949,10 @@ struct HomeView: View { private func primaryActionTapped() { guard !isValidatingPairingFile else { return } + if pairingFileLikelyInvalid { + isShowingPairingFilePicker = true + return + } if pairingFileExists { if !ddiMounted { showAlert(title: "Device Not Mounted".localized, message: "The Developer Disk Image has not been mounted yet. Check in settings for more information.".localized, showOk: true) { _ in } diff --git a/StikJIT/Views/MainTabView.swift b/StikJIT/Views/MainTabView.swift index fdab8c2..dd7a502 100644 --- a/StikJIT/Views/MainTabView.swift +++ b/StikJIT/Views/MainTabView.swift @@ -41,10 +41,6 @@ struct MainTabView: View { .tabItem { Label("Scripts", systemImage: "scroll") } .tag(1) - IPAAppManagerView() - .tabItem { Label("Testing", systemImage: "square.grid.2x2.fill") } - .tag(2) - DeviceInfoView() .tabItem { Label("Device Info", systemImage: "info.circle.fill") } .tag(3) diff --git a/StikJIT/Views/SettingsView.swift b/StikJIT/Views/SettingsView.swift index 1ef3342..aae96d4 100644 --- a/StikJIT/Views/SettingsView.swift +++ b/StikJIT/Views/SettingsView.swift @@ -209,7 +209,7 @@ struct SettingsView: View { glassCard { VStack(spacing: 16) { VStack { - Image("StikJIT") + Image("StikDebug") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 80, height: 80) diff --git a/StikJIT/Views/SharedStubs.swift b/StikJIT/Views/SharedStubs.swift index 45f6019..92ce984 100644 --- a/StikJIT/Views/SharedStubs.swift +++ b/StikJIT/Views/SharedStubs.swift @@ -10,6 +10,26 @@ import UniformTypeIdentifiers import UIKit // MARK: - Shared glass card wrapper (renamed to avoid conflicts) +struct MaterialCard: View { + let content: Content + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + var body: some View { + content + .padding(20) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) + } +} @ViewBuilder func appGlassCard(@ViewBuilder _ content: () -> Content) -> some View { diff --git a/StikJIT/Views/Testing.swift b/StikJIT/Views/Testing.swift deleted file mode 100644 index c1fe7c0..0000000 --- a/StikJIT/Views/Testing.swift +++ /dev/null @@ -1,1184 +0,0 @@ -// -// Testing.swift -// StikDebug -// -// Created by Stephen Bove on 8/8/25. -// - -import SwiftUI -import Foundation -import UniformTypeIdentifiers -import ZIPFoundation -import ZSign -import StikImporter -import UIKit -import PhotosUI - -// MARK: - C bridge - -@_silgen_name("install_ipa") -private func c_install_ipa( - _ ip: UnsafePointer, - _ pairing: UnsafePointer, - _ udid: UnsafePointer?, - _ ipa: UnsafePointer -) -> Int32 - -private func installErrorMessage(_ code: Int32) -> String { - switch code { - case 0: return "Success" - case 1: return "Pairing file unreadable" - case 2: return "TCP provider error" - case 3: return "AFC connect error" - case 4: return "IPA unreadable" - case 5: return "AFC open error" - case 6: return "AFC write error" - case 7: return "Install-proxy error" - case 8: return "Device refused IPA" - case 9: return "Invalid IP address" - default: return "Unknown (\(code))" - } -} - -// MARK: - Files - -private let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] -private let appsJSON = docs.appendingPathComponent("signed_apps.json") -private let certsJSON = docs.appendingPathComponent("certs.json") -// Repos disabled for now -// private let reposJSON = docs.appendingPathComponent("repos.json") - -// MARK: - Models - -struct SignedApp: Identifiable, Codable, Hashable { - let id: UUID - let name: String - let bundleID: String - let version: String - let ipaPath: String - let iconPath: String? - - var ipaURL: URL { docs.appendingPathComponent(ipaPath) } - var iconURL: URL? { iconPath.map { docs.appendingPathComponent($0) } } -} - -struct Certificate: Identifiable, Codable, Hashable { - let id: UUID - let name: String - let p12Path: String - let mobPath: String? - - var p12URL: URL { docs.appendingPathComponent(p12Path) } - var mobURL: URL? { mobPath.map { docs.appendingPathComponent($0) } } -} - -// MARK: - FS helpers - -private enum FSX { - static func mkdir(_ url: URL) throws { - if !FileManager.default.fileExists(atPath: url.path) { - try FileManager.default.createDirectory(at: url, - withIntermediateDirectories: true, - attributes: [.posixPermissions: 0o755]) - } - } - static func copySecure(from src: URL, to dst: URL) throws { - let ok = src.startAccessingSecurityScopedResource() - defer { if ok { src.stopAccessingSecurityScopedResource() } } - if FileManager.default.fileExists(atPath: dst.path) { try FileManager.default.removeItem(at: dst) } - try FileManager.default.copyItem(at: src, to: dst) - try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: dst.path) - } - static func perms(app: URL) throws { - let keys: [URLResourceKey] = [.isDirectoryKey, .isExecutableKey] - guard let e = FileManager.default.enumerator(at: app, includingPropertiesForKeys: keys) else { return } - for case let f as URL in e { - let rv = try f.resourceValues(forKeys: Set(keys)) - if rv.isDirectory == true { - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: f.path) - } else { - let exec = rv.isExecutable == true || - f.lastPathComponent == app.lastPathComponent.replacingOccurrences(of: ".app", with: "") || - (f.pathExtension.isEmpty && !f.lastPathComponent.contains(".")) - try FileManager.default.setAttributes([.posixPermissions: exec ? 0o755 : 0o644], - ofItemAtPath: f.path) - } - } - } -} - -// MARK: - App Signer Manager - -@MainActor -final class AppSignerManager: ObservableObject { - private var didFinishInitialLoad = false - - @Published var apps: [SignedApp] = [] { - didSet { if didFinishInitialLoad { saveApps() } } - } - @Published var certs: [Certificate] = [] { - didSet { if didFinishInitialLoad { saveCerts() } } - } - @Published var selectedCertID: UUID? - @Published var busy = false - @Published var ipaURL: URL? - @Published var ipaName: String? - - /// Parsed from the IPA (for reference) - @Published var detectedName: String? - @Published var detectedBundleID: String? - @Published var detectedVersion: String? - - /// Advanced overrides (used if non-empty; bundleId only if `useCustomBundleID`) - @Published var advName: String = "" - @Published var advBundleID: String = "" - @Published var advVersion: String = "" - @Published var useCustomBundleID: Bool = false - - /// Advanced icon overrides - @Published var useCustomIcon: Bool = false - @Published var advIconURL: URL? - - let imports = docs.appendingPathComponent("Imports", isDirectory: true) - let certsFolder = docs.appendingPathComponent("Certs", isDirectory: true) - - init() { - try? FSX.mkdir(imports) - try? FSX.mkdir(certsFolder) - migrateAndLoadApps() - migrateAndLoadCerts() - didFinishInitialLoad = true - } - - private func migrateAndLoadApps() { - guard let data = try? Data(contentsOf: appsJSON), - let stored = try? JSONDecoder().decode([SignedApp].self, from: data) else { return } - - var fixed: [SignedApp] = [] - for app in stored { - if FileManager.default.fileExists(atPath: app.ipaURL.path) { - fixed.append(app) - continue - } - let expectedRel = "SignedApps/\(app.name)/\(app.name).ipa" - let newAbs = docs.appendingPathComponent(expectedRel).path - if FileManager.default.fileExists(atPath: newAbs) { - let newIconRel = "SignedApps/\(app.name)/icon.png" - fixed.append(SignedApp( - id: app.id, name: app.name, bundleID: app.bundleID, - version: app.version, ipaPath: expectedRel, - iconPath: FileManager.default.fileExists(atPath: docs.appendingPathComponent(newIconRel).path) ? newIconRel : nil)) - } - } - apps = fixed - } - private func saveApps() { - guard let data = try? JSONEncoder().encode(apps) else { return } - try? data.write(to: appsJSON) - } - - private func migrateAndLoadCerts() { - guard let data = try? Data(contentsOf: certsJSON), - let stored = try? JSONDecoder().decode([Certificate].self, from: data) else { return } - - var fixed: [Certificate] = [] - for cert in stored { - if FileManager.default.fileExists(atPath: cert.p12URL.path) && - (cert.mobURL == nil || FileManager.default.fileExists(atPath: cert.mobURL!.path)) { - fixed.append(cert) - continue - } - let dir = certsFolder.appendingPathComponent(cert.id.uuidString) - let newP12Rel = "Certs/\(cert.id.uuidString)/cert.p12" - let newMobRel = "Certs/\(cert.id.uuidString)/profile.mobileprovision" - if FileManager.default.fileExists(atPath: dir.appendingPathComponent("cert.p12").path) { - fixed.append(Certificate( - id: cert.id, name: cert.name, - p12Path: newP12Rel, - mobPath: FileManager.default.fileExists(atPath: dir.appendingPathComponent("profile.mobileprovision").path) ? newMobRel : nil)) - } - } - certs = fixed - if selectedCertID == nil { selectedCertID = certs.first?.id } - } - private func saveCerts() { - guard let data = try? JSONEncoder().encode(certs) else { return } - try? data.write(to: certsJSON) - } - - func deleteApp(_ app: SignedApp) { - if let idx = apps.firstIndex(of: app) { - try? FileManager.default.removeItem(at: app.ipaURL.deletingLastPathComponent()) - apps.remove(at: idx) - } - } - - func deleteCert(_ cert: Certificate) { - let dir = certsFolder.appendingPathComponent(cert.id.uuidString) - try? FileManager.default.removeItem(at: dir) - KeychainHelper.shared.deletePassword(forKey: cert.id.uuidString) - certs.removeAll { $0.id == cert.id } - if selectedCertID == cert.id { selectedCertID = certs.first?.id } - saveCerts() - } - - func importIPA(from url: URL) throws { - let dst = imports.appendingPathComponent("current.ipa") - try FSX.copySecure(from: url, to: dst) - ipaURL = dst - ipaName = try parseIPAName(at: dst) - try? parseIPAMetadata(at: dst) - } - func importIPAFromDownloads(_ localURL: URL) throws { - let dst = imports.appendingPathComponent("current.ipa") - if FileManager.default.fileExists(atPath: dst.path) { try FileManager.default.removeItem(at: dst) } - try FileManager.default.copyItem(at: localURL, to: dst) - ipaURL = dst - ipaName = try? parseIPAName(at: dst) - try? parseIPAMetadata(at: dst) - } - - private func parseIPAName(at ipa: URL) throws -> String? { - let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - try FSX.mkdir(tmp) - try FileManager.default.unzipItem(at: ipa, to: tmp) - let payload = tmp.appendingPathComponent("Payload") - let appDir = try FileManager.default.contentsOfDirectory(at: payload, includingPropertiesForKeys: nil) - .first { $0.pathExtension == "app" } - defer { try? FileManager.default.removeItem(at: tmp) } - guard let dir = appDir, - let dict = NSDictionary(contentsOf: dir.appendingPathComponent("Info.plist")) else { return nil } - return (dict["CFBundleDisplayName"] ?? dict["CFBundleName"]) as? String - } - - private func parseIPAMetadata(at ipa: URL) throws { - let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - try FSX.mkdir(tmp) - try FileManager.default.unzipItem(at: ipa, to: tmp) - let payload = tmp.appendingPathComponent("Payload") - let appDir = try FileManager.default.contentsOfDirectory(at: payload, includingPropertiesForKeys: nil) - .first { $0.pathExtension == "app" } - defer { try? FileManager.default.removeItem(at: tmp) } - guard let dir = appDir, - let dict = NSDictionary(contentsOf: dir.appendingPathComponent("Info.plist")) as? [String: Any] else { return } - detectedName = (dict["CFBundleDisplayName"] ?? dict["CFBundleName"]) as? String - detectedBundleID = dict["CFBundleIdentifier"] as? String - detectedVersion = dict["CFBundleShortVersionString"] as? String - } - - func addCertificate(name: String, p12: URL, mob: URL, password: String, - onErr: (String,String)->Void, onOK: (String,String)->Void) { - let id = UUID() - let dir = certsFolder.appendingPathComponent(id.uuidString, isDirectory: true) - do { - try FSX.mkdir(dir) - try FSX.copySecure(from: p12, to: dir.appendingPathComponent("cert.p12")) - try FSX.copySecure(from: mob, to: dir.appendingPathComponent("profile.mobileprovision")) - KeychainHelper.shared.save(password: password, forKey: id.uuidString) - - certs.append(Certificate( - id: id, name: name, - p12Path: "Certs/\(id.uuidString)/cert.p12", - mobPath: "Certs/\(id.uuidString)/profile.mobileprovision")) - selectedCertID = id - onOK("Certificate added", name) - } catch { onErr("Cert error", error.localizedDescription) } - } - - func signIPA(onErr: @escaping (String,String)->Void, - onOK: @escaping (String,String)->Void) { - guard !busy else { return } - guard let ipa = ipaURL, - let cert = certs.first(where: { $0.id == selectedCertID }) else { - onErr("Missing data", "Import IPA and select certificate first"); return - } - busy = true - Task.detached { [weak self] in - guard let self else { return } - do { - let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - try FSX.mkdir(tmp) - _ = ipa.startAccessingSecurityScopedResource(); defer { ipa.stopAccessingSecurityScopedResource() } - - try FileManager.default.unzipItem(at: ipa, to: tmp) - let payload = tmp.appendingPathComponent("Payload") - guard let appFolder = try FileManager.default.contentsOfDirectory(at: payload, includingPropertiesForKeys: nil) - .first(where: { $0.pathExtension == "app" }) else { - throw NSError(domain: "Signer", code: 1, userInfo: [NSLocalizedDescriptionKey: "No .app found"]) - } - try FSX.perms(app: appFolder) - - let plistURL = appFolder.appendingPathComponent("Info.plist") - guard - let dict = NSDictionary(contentsOf: plistURL) as? [String: Any], - let curName = (dict["CFBundleDisplayName"] ?? dict["CFBundleName"]) as? String, - let curBundle = dict["CFBundleIdentifier"] as? String, - let curVer = dict["CFBundleShortVersionString"] as? String - else { - throw NSError(domain: "Signer", code: 2, userInfo: [NSLocalizedDescriptionKey: "Info.plist parse error"]) - } - - // Capture advanced fields on MainActor - var trimmedName: String = "" - var trimmedBID: String = "" - var trimmedVer: String = "" - var useCustomBID: Bool = false - var useCustomIcon: Bool = false - var pickedIconURL: URL? - await MainActor.run { - trimmedName = self.advName.trimmingCharacters(in: .whitespacesAndNewlines) - trimmedBID = self.advBundleID.trimmingCharacters(in: .whitespacesAndNewlines) - trimmedVer = self.advVersion.trimmingCharacters(in: .whitespacesAndNewlines) - useCustomBID = self.useCustomBundleID - useCustomIcon = self.useCustomIcon - pickedIconURL = self.advIconURL - } - - let newName = trimmedName.isEmpty ? curName : trimmedName - let newBundle = (useCustomBID && !trimmedBID.isEmpty) ? trimmedBID : curBundle - let newVer = trimmedVer.isEmpty ? curVer : trimmedVer - - // Write overrides into Info.plist BEFORE signing - if let m = NSMutableDictionary(contentsOf: plistURL) { - m["CFBundleDisplayName"] = newName - m["CFBundleName"] = newName - m["CFBundleShortVersionString"] = newVer - m["CFBundleIdentifier"] = newBundle - m.write(to: plistURL, atomically: true) - } - - // If requested, generate a full icon set and update CFBundleIcons - if useCustomIcon, let url = pickedIconURL, - let img = UIImage(contentsOfFile: url.path) { - let icon1024URL = try await self.writeIconSet(image: img, into: appFolder) - if let m = NSMutableDictionary(contentsOf: plistURL) { - let primary: [String: Any] = [ - "CFBundleIconFiles": ["AppIcon20", "AppIcon29", "AppIcon40", "AppIcon60", "AppIcon76", "AppIcon83.5"] - ] - m["CFBundleIcons"] = ["CFBundlePrimaryIcon": primary] - m.write(to: plistURL, atomically: true) - } - // Provide a preview fallback for our UI list - let previewDest = appFolder.appendingPathComponent("icon.png") - try? FileManager.default.removeItem(at: previewDest) - try? FileManager.default.copyItem(at: icon1024URL, to: previewDest) - } - - let pw = KeychainHelper.shared.readPassword(forKey: cert.id.uuidString) ?? "" - let rc = zsign( - appFolder.path, - cert.p12URL.path, - cert.p12URL.path, - cert.mobURL?.path ?? "", - pw - ) - guard rc == 0 else { - throw NSError(domain: "zsign", code: Int(rc), - userInfo: [NSLocalizedDescriptionKey: "zsign returned \(rc)"]) - } - - let outDir = docs.appendingPathComponent("SignedApps/\(newName)", isDirectory: true) - if FileManager.default.fileExists(atPath: outDir.path) { try FileManager.default.removeItem(at: outDir) } - try FSX.mkdir(outDir) - try FileManager.default.zipItem(at: payload, - to: outDir.appendingPathComponent("\(newName).ipa"), - shouldKeepParent: true) - try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: outDir.appendingPathComponent("\(newName).ipa").path) - - var iconRel: String? - if let iconURL = try? FileManager.default.contentsOfDirectory(at: appFolder, includingPropertiesForKeys: nil) - .first(where: { $0.lastPathComponent.lowercased().contains("appicon") && $0.pathExtension == "png" }) { - let dst = outDir.appendingPathComponent("icon.png") - try? FileManager.default.copyItem(at: iconURL, to: dst) - iconRel = "SignedApps/\(newName)/icon.png" - } else if FileManager.default.fileExists(atPath: appFolder.appendingPathComponent("icon.png").path) { - let dst = outDir.appendingPathComponent("icon.png") - try? FileManager.default.copyItem(at: appFolder.appendingPathComponent("icon.png"), to: dst) - iconRel = "SignedApps/\(newName)/icon.png" - } - - let relIPA = "SignedApps/\(newName)/\(newName).ipa" - let newApp = SignedApp( - id: UUID(), name: newName, bundleID: newBundle, - version: newVer, ipaPath: relIPA, iconPath: iconRel) - - await MainActor.run { - self.apps.removeAll { $0.name == newApp.name } - self.apps.append(newApp) - self.busy = false - onOK("\(newName) is ready for testing", "Saved to \(relIPA)") - } - } catch { - await MainActor.run { - self.busy = false - onErr("Sign error", error.localizedDescription) - } - } - } - } - - func install(app: SignedApp, ip: String, pairing: String, - onErr: @escaping (String,String)->Void, - onOK: @escaping (String,String)->Void) { - guard !busy else { return } - guard FileManager.default.fileExists(atPath: app.ipaURL.path) else { onErr("IPA missing", app.ipaURL.path); return } - guard FileManager.default.fileExists(atPath: pairing) else { onErr("Pairing missing", pairing); return } - busy = true - Task.detached { [weak self] in - guard let self else { return } - let rc = ip.withCString { ipPtr in - pairing.withCString { pairPtr in - app.ipaURL.path.withCString { ipaPtr in - c_install_ipa(ipPtr, pairPtr, nil, ipaPtr) - } - } - } - await MainActor.run { - self.busy = false - rc == 0 ? onOK("Installed for testing", app.name) - : onErr("Install failed", installErrorMessage(rc)) - } - } - } - - // MARK: - Icon writer helpers - - /// Writes all required icon PNGs into `appFolder` and returns the 1024px icon path. - private func writeIconSet(image: UIImage, into appFolder: URL) throws -> URL { - typealias IconOut = (px: CGFloat, suffix: String) - struct IconSpec { let base: String; let outs: [IconOut] } - let specs: [IconSpec] = [ - IconSpec(base: "AppIcon20", outs: [(40,"@2x"), (60,"@3x")]), - IconSpec(base: "AppIcon29", outs: [(58,"@2x"), (87,"@3x")]), - IconSpec(base: "AppIcon40", outs: [(80,"@2x"), (120,"@3x")]), - IconSpec(base: "AppIcon60", outs: [(120,"@2x"), (180,"@3x")]), - IconSpec(base: "AppIcon76", outs: [(76,""), (152,"@2x")]), - IconSpec(base: "AppIcon83.5", outs: [(167,"@2x")]) - ] - for spec in specs { - for out in spec.outs { - let file = "\(spec.base)\(out.suffix).png" - let url = appFolder.appendingPathComponent(file) - if let data = pngData(from: image, side: out.px) { - try data.write(to: url, options: .atomic) - try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: url.path) - } - } - } - // 1024 marketing icon for previews - let icon1024 = appFolder.appendingPathComponent("AppIcon-1024.png") - if let data = pngData(from: image, side: 1024) { - try data.write(to: icon1024, options: .atomic) - try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: icon1024.path) - } - return icon1024 - } - - private func pngData(from image: UIImage, side: CGFloat) -> Data? { - let size = CGSize(width: side, height: side) - let renderer = UIGraphicsImageRenderer(size: size) - return renderer.pngData { _ in - image.draw(in: CGRect(origin: .zero, size: size)) - } - } -} - -// Reusable MaterialCard matching SettingsView.materialCard styling -struct MaterialCard: View { - let content: Content - init(@ViewBuilder content: () -> Content) { - self.content = content() - } - var body: some View { - content - .padding(20) - .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) - ) - ) - .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) - .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) - } -} - -struct GlassCapsuleButtonStyle: ButtonStyle { - var minWidth: CGFloat? = nil - var height: CGFloat = 40 - var strokeOpacity: Double = 0.16 - func makeBody(configuration: Configuration) -> some View { - configuration.label - .frame(minWidth: minWidth) - .frame(height: height) - .padding(.horizontal, 14) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(.white.opacity(strokeOpacity), lineWidth: 1) - ) - .shadow(color: .black.opacity(0.08), radius: configuration.isPressed ? 4 : 10, x: 0, y: configuration.isPressed ? 1 : 4) - .scaleEffect(configuration.isPressed ? 0.98 : 1) - } -} - -struct GlassIconButtonStyle: ButtonStyle { - var size: CGFloat = 40 - var strokeOpacity: Double = 0.14 - func makeBody(configuration: Configuration) -> some View { - configuration.label - .frame(width: size, height: size) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(.white.opacity(strokeOpacity), lineWidth: 1) - ) - .shadow(color: .black.opacity(0.08), radius: configuration.isPressed ? 4 : 10, x: 0, y: configuration.isPressed ? 1 : 4) - .scaleEffect(configuration.isPressed ? 0.98 : 1) - } -} - -// Small capsule "BETA" tag for nav bar -private struct BetaTag: View { - var body: some View { - Text("BETA") - .font(.caption2.weight(.bold)) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color.orange.opacity(0.9), in: Capsule()) - .foregroundStyle(.white) - .overlay(Capsule().stroke(.white.opacity(0.2), lineWidth: 1)) - .accessibilityLabel("Beta") - } -} - -// MARK: - Custom glassy tab switcher (Repos disabled; only Testing) - -enum ManagerSection: String, CaseIterable, Identifiable { - case testing = "Testing" - // case repos = "Repos" - var id: String { rawValue } -} - -// MARK: - Liquid Glass Tab Switcher - -struct GlassLiquidTabSwitcher: View { - @Binding var selection: ManagerSection - var accent: Color = .white - var height: CGFloat = 56 - - private let pad: CGFloat = 6 - private let pillInset: CGFloat = 4 - private let stretchMax: CGFloat = 0.18 - - @GestureState private var dragX: CGFloat? = nil - @State private var isDragging = false - @State private var lastIndex: Int = 0 - - private var items: [ManagerSection] { [.testing] } // only testing - - var body: some View { - // Hidden per request (testing-repo selector off) - EmptyView() - } - - private func clamp(_ v: T, min lo: T, max hi: T) -> T { - Swift.max(lo, Swift.min(v, hi)) - } -} - -// MARK: - Reusable full-width glass row - -private struct GlassInputRow: View { - let leadingIcon: String - let title: String - let statusIcon: String? - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack(spacing: 12) { - Image(systemName: leadingIcon).imageScale(.medium) - Text(title).font(.body).lineLimit(1).truncationMode(.tail) - Spacer() - if let si = statusIcon { Image(systemName: si).imageScale(.medium) } - Image(systemName: "chevron.right").imageScale(.medium).opacity(0.8) - } - .padding(.vertical, 14) - .padding(.horizontal, 14) - .frame(maxWidth: .infinity) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(.white.opacity(0.14), lineWidth: 1) - ) - .shadow(color: .black.opacity(0.08), radius: 8, x: 0, y: 3) - } - .buttonStyle(.plain) - } -} - -// MARK: - Certificate picker sheet - -private struct CertPickerSheet: View { - let certs: [Certificate] - @Binding var selectedID: UUID? - var onAdd: () -> Void - var onDelete: (Certificate) -> Void - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationStack { - List { - Section("Certificates") { - ForEach(certs) { cert in - HStack { - Image(systemName: "checkmark.seal").foregroundStyle(.secondary) - Text(cert.name) - Spacer() - if selectedID == cert.id { - Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) - } - } - .contentShape(Rectangle()) - .onTapGesture { selectedID = cert.id; dismiss() } - } - .onDelete { idx in - for i in idx { - let c = certs[i] - onDelete(c) - } - } - Button { - dismiss() - onAdd() - } label: { - HStack { - Image(systemName: "plus.circle.fill") - Text("Add Certificate") - } - } - } - } - .navigationTitle("Choose Certificate") - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Close") { dismiss() } - } - } - } - } -} - -// MARK: - Manage Certificates (delete) - -private struct ManageCertsView: View { - @Binding var certs: [Certificate] - @Binding var selected: UUID? - var onDelete: (Certificate) -> Void - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationStack { - List { - ForEach(certs) { cert in - HStack { - VStack(alignment: .leading) { - Text(cert.name).font(.body) - Text(cert.id.uuidString).font(.caption2).foregroundStyle(.secondary) - } - Spacer() - if selected == cert.id { - Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) - } - } - .contentShape(Rectangle()) - .onTapGesture { selected = cert.id } - } - .onDelete { idx in - for i in idx { - let c = certs[i] - onDelete(c) - } - } - } - .navigationTitle("Certificates") - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { dismiss() } - } - } - } - } -} - -// MARK: - Top-level view (Repos disabled) - -struct IPAAppManagerView: View { - @AppStorage("customAccentColor") private var customAccentColorHex: String = "" - @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue - @Environment(\.themeExpansionManager) private var themeExpansion - - private var accentColor: Color { - themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue - } - private var backgroundStyle: BackgroundStyle { - themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle - } - private var preferredScheme: ColorScheme? { - themeExpansion?.preferredColorScheme(for: appThemeRaw) - } - - @StateObject private var mgr = AppSignerManager() - - @State private var pickerShown = false - @State private var showAddCert = false - @State private var showManageCerts = false - @State private var showCertPickerSheet = false - - // CustomErrorView overlay state - @State private var alertVisible = false - @State private var alertTitle = "" - @State private var alertMsg = "" - @State private var alertType: MessageType = .info - - @AppStorage("deviceIP") private var deviceIP = "10.7.0.2" - private var pairingPath: String { docs.appendingPathComponent("pairingFile.plist").path } - - @State private var section: ManagerSection = .testing - - // For auto-scroll to “Prepare for testing” - @State private var scrollProxy: ScrollViewProxy? - private let signCardAnchor = "signCardAnchor" - - // Advanced options UI state - @State private var showAdvanced = false - @State private var pickIcon = false - @State private var photoItem: PhotosPickerItem? = nil // Photos picker - - var body: some View { - NavigationStack { - ZStack { - ThemedBackground(style: backgroundStyle) - .ignoresSafeArea() - - ScrollViewReader { proxy in - ScrollView { - VStack(spacing: 16) { - // Selector hidden per request - // GlassLiquidTabSwitcher(selection: $section) - - // Only Testing section - signCard.id(signCardAnchor) - appsCard - } - .padding(.horizontal, 16) - .padding(.vertical, 20) - } - .onAppear { scrollProxy = proxy } - } - - if mgr.busy { - Color.black.opacity(0.25).ignoresSafeArea() - ProgressView() - .padding(24) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(.white.opacity(0.20), lineWidth: 1) - ) - .shadow(color: .black.opacity(0.20), radius: 20, x: 0, y: 10) - } - - if alertVisible { - CustomErrorView( - title: alertTitle, - message: alertMsg, - onDismiss: { alertVisible = false }, - showButton: true, - primaryButtonText: "OK", - messageType: alertType - ) - } - } - // Show nav bar title again - .navigationTitle("Testing") - .navigationBarTitleDisplayMode(.large) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - BetaTag() - } - } - .stikImporter(isPresented: $pickerShown, - selectedURLs: .constant([]), - allowedContentTypes: [.item], - allowsMultipleSelection: false) { urls in - if let u = urls.first { - do { - try mgr.importIPA(from: u) - notify("File imported", "File saved to Imports") - jumpToSigning() - } catch { - fail("Import error", error.localizedDescription) - } - } - } - .sheet(isPresented: $showAddCert) { - AddCertView(accent: accentColor) { n, p12, mob, pw in - mgr.addCertificate(name: n, p12: p12, mob: mob, password: pw, - onErr: fail, onOK: notify) - } - } - .sheet(isPresented: $showManageCerts) { - ManageCertsView(certs: $mgr.certs, - selected: $mgr.selectedCertID, - onDelete: { cert in mgr.deleteCert(cert) }) - } - .tint(accentColor) - .preferredColorScheme(preferredScheme) - // Photos picker → temp PNG saved → advIconURL - .onChange(of: photoItem) { newItem in - guard let newItem else { return } - Task { - if let data = try? await newItem.loadTransferable(type: Data.self), - let img = UIImage(data: data), - let png = img.pngData() { - - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("custom-icon-\(UUID().uuidString).png") - do { - try png.write(to: url, options: .atomic) - await MainActor.run { mgr.advIconURL = url } - } catch { - await MainActor.run { fail("Icon error", error.localizedDescription) } - } - - } else if let fileURL = try? await newItem.loadTransferable(type: URL.self) { - do { - let data = try Data(contentsOf: fileURL) - if let img = UIImage(data: data), - let png = img.pngData() { - - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("custom-icon-\(UUID().uuidString).png") - try png.write(to: url, options: .atomic) - await MainActor.run { mgr.advIconURL = url } - } else { - await MainActor.run { fail("Icon error", "Could not decode image data") } - } - } catch { - await MainActor.run { fail("Icon error", error.localizedDescription) } - } - - } else { - await MainActor.run { fail("Icon error", "Unsupported photo representation") } - } - } - } - } - } - - // MARK: - Testing UI - - private var selectedCertName: String { - mgr.certs.first(where: { $0.id == mgr.selectedCertID })?.name ?? "Select certificate" - } - - private var signCard: some View { - MaterialCard { - VStack(alignment: .leading, spacing: 18) { - HStack { - Text("Prepare for testing") - .font(.headline) - .foregroundStyle(.primary) - Spacer() - } - - // Certificate - GlassInputRow( - leadingIcon: "checkmark.seal.fill", - title: selectedCertName, - statusIcon: mgr.selectedCertID == nil ? nil : "checkmark.circle.fill" - ) { showCertPickerSheet.toggle() } - .sheet(isPresented: $showCertPickerSheet) { - CertPickerSheet( - certs: mgr.certs, - selectedID: $mgr.selectedCertID, - onAdd: { showAddCert = true }, - onDelete: { cert in mgr.deleteCert(cert) } - ) - } - - // IPA file - GlassInputRow( - leadingIcon: "arrow.down.doc", - title: mgr.ipaName ?? ".IPA File", - statusIcon: mgr.ipaURL == nil ? nil : "checkmark.circle.fill" - ) { pickerShown = true } - - // Advanced Options - DisclosureGroup(isExpanded: $showAdvanced) { - VStack(spacing: 10) { - TextField("", text: $mgr.advName, prompt: Text("Custom app name")) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .padding(.vertical, 10) - .padding(.horizontal, 12) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(.white.opacity(0.14), lineWidth: 1)) - - Toggle(isOn: $mgr.useCustomBundleID) { Text("Use custom bundle ID") } - .toggleStyle(.switch) - - TextField("", text: $mgr.advBundleID, prompt: Text("Custom bundle ID")) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .padding(.vertical, 10) - .padding(.horizontal, 12) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(.white.opacity(0.14), lineWidth: 1)) - .disabled(!mgr.useCustomBundleID) - .opacity(mgr.useCustomBundleID ? 1 : 0.5) - - TextField("", text: $mgr.advVersion, prompt: Text("Custom version")) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .keyboardType(.numbersAndPunctuation) - .padding(.vertical, 10) - .padding(.horizontal, 12) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(.white.opacity(0.14), lineWidth: 1)) - - Toggle(isOn: $mgr.useCustomIcon) { Text("Use custom app icon") } - .toggleStyle(.switch) - - PhotosPicker(selection: $photoItem, matching: .images) { - HStack { - Image(systemName: "photo.on.rectangle") - Text(mgr.advIconURL == nil ? "Choose from Photos…" : "Photo selected") - Spacer() - Image(systemName: mgr.advIconURL == nil ? "chevron.right" : "checkmark.circle.fill") - .imageScale(.medium) - } - } - .buttonStyle(GlassCapsuleButtonStyle(minWidth: nil, height: 40)) - .disabled(!mgr.useCustomIcon) - .opacity(mgr.useCustomIcon ? 1 : 0.5) - - Button { - pickIcon = true - } label: { - HStack { - Image(systemName: "folder") - Text("Choose PNG from Files…") - Spacer() - Image(systemName: "chevron.right").imageScale(.medium) - } - } - .buttonStyle(GlassCapsuleButtonStyle(minWidth: nil, height: 40)) - .disabled(!mgr.useCustomIcon) - .opacity(mgr.useCustomIcon ? 1 : 0.5) - .stikImporter( - isPresented: $pickIcon, - selectedURLs: .constant([]), - allowedContentTypes: [UTType.png], - allowsMultipleSelection: false - ) { urls in - mgr.advIconURL = urls.first - } - - Text("Note: The provisioning profile must match the bundle ID. If not, signing will fail.") - .font(.footnote) - .foregroundStyle(.secondary) - .padding(.top, 2) - } - .padding(.top, 8) - } label: { - HStack(spacing: 8) { - Image(systemName: "slider.horizontal.3") - Text("Advanced Options") - Spacer() - } - .contentShape(Rectangle()) - } - .padding(.top, 2) - - Button("Prepare") { mgr.signIPA(onErr: fail, onOK: notify) } - .frame(maxWidth: .infinity, minHeight: 48) - .background(accentColor, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(.white.opacity(0.18), lineWidth: 1) - ) - .foregroundColor(accentColor.contrastText()) - // Removed glow/shadow from the Prepare button - .disabled(mgr.busy || mgr.ipaURL == nil || mgr.selectedCertID == nil) - } - .padding(8) - } - } - - private var appsCard: some View { - MaterialCard { - VStack(alignment: .leading, spacing: 18) { - Text("Ready to test apps") - .font(.headline) - .foregroundStyle(.primary) - - if mgr.apps.isEmpty { - // Full-width placeholder matching card content width - HStack(spacing: 12) { - Image(systemName: "app.dashed") - .imageScale(.medium) - .foregroundStyle(.secondary) - Text("No ready to test apps yet") - .foregroundStyle(.secondary) - Spacer() - } - .padding(.vertical, 14) - .padding(.horizontal, 14) - .frame(maxWidth: .infinity) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(.white.opacity(0.14), lineWidth: 1) - ) - .shadow(color: .black.opacity(0.08), radius: 8, x: 0, y: 3) - } else { - ForEach(mgr.apps) { app in - HStack(spacing: 12) { - if let img = app.iconURL.flatMap({ UIImage(contentsOfFile: $0.path) }) { - Image(uiImage: img) - .resizable() - .frame(width: 44, height: 44) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(.white.opacity(0.15), lineWidth: 0.5) - ) - } else { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(.ultraThinMaterial) - .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(.white.opacity(0.12), lineWidth: 0.5)) - .frame(width: 44, height: 44) - .overlay(Image(systemName: "app.fill").opacity(0.6)) - } - VStack(alignment: .leading, spacing: 2) { - Text(app.name).bold() - Text("v\(app.version) • \(app.bundleID)") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - Button("Test") { - mgr.install(app: app, ip: deviceIP, pairing: pairingPath, - onErr: fail, onOK: notify) - } - .buttonStyle(GlassCapsuleButtonStyle(minWidth: 72, height: 40, strokeOpacity: 0.18)) - .disabled(mgr.busy) - - Button(role: .destructive) { mgr.deleteApp(app) } label: { - Image(systemName: "trash") - } - .buttonStyle(GlassIconButtonStyle()) - } - .padding(.vertical, 6) - Divider().opacity(0.25) - } - } - } - .padding(8) - } - } - - // MARK: - Helpers (CustomErrorView) - - private func fail(_ title: String, _ msg: String) { - alertTitle = title - alertMsg = msg - alertType = .error - alertVisible = true - } - private func notify(_ title: String, _ msg: String) { - alertTitle = title - alertMsg = msg - alertType = .success - alertVisible = true - } - private func jumpToSigning() { - withAnimation(.easeInOut) { - section = .testing - scrollProxy?.scrollTo(signCardAnchor, anchor: .top) - } - } -} - -// MARK: - Add Certificate sheet - -private struct AddCertView: View { - let accent: Color - var onSave: (String, URL, URL, String) -> Void - @Environment(\.dismiss) private var dismiss - - @State private var name = "" - @State private var p12URL: URL? - @State private var mobURL: URL? - @State private var password = "" - - @State private var pickP12 = false - @State private var pickMob = false - - var body: some View { - NavigationStack { - Form { - Section { - TextField("Certificate name", text: $name) - importRow(label: ".mobileprovision", picked: mobURL != nil) { pickMob = true } - .stikImporter(isPresented: $pickMob, - selectedURLs: .constant([]), - allowedContentTypes: [UTType.item], - allowsMultipleSelection: false) { mobURL = $0.first } - importRow(label: ".p12 file", picked: p12URL != nil) { pickP12 = true } - .stikImporter(isPresented: $pickP12, - selectedURLs: .constant([]), - allowedContentTypes: [UTType.item], - allowsMultipleSelection: false) { p12URL = $0.first } - SecureField("p12 password", text: $password) - } - } - .navigationTitle("New Certificate") - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - if let p = p12URL, let m = mobURL, !name.isEmpty { - onSave(name, p, m, password); dismiss() - } - } - .disabled(p12URL == nil || mobURL == nil || name.isEmpty) - } - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - } - } - } - .tint(accent) - } - - private func importRow(label: String, picked: Bool, - action: @escaping () -> Void) -> some View { - Button(action: action) { - HStack { - Text(label) - Spacer() - Image(systemName: picked ? "checkmark.circle.fill" : "chevron.right") - } - } - .buttonStyle(GlassCapsuleButtonStyle(minWidth: nil, height: 40)) - } -}