diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml new file mode 100644 index 00000000..7d132c4f --- /dev/null +++ b/.github/workflows/mobile.yml @@ -0,0 +1,115 @@ +name: Build iBridge Mobile + +on: + push: + branches: + - "*" + pull_request: + branches: + - "*" + workflow_dispatch: + release: + types: [published] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: write + +jobs: + mobile: + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + + - name: Install Rust iOS target + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install frontend dependencies + run: bun i --frozen-lockfile + + - name: Initialize Tauri iOS project + run: bun tauri ios init --ci + + - name: Build frontend assets + run: bun run build + + - name: Compile unsigned Tauri iOS app + shell: bash + run: | + set -euo pipefail + PROJECT=$(find src-tauri/gen/apple -maxdepth 1 -name '*.xcodeproj' -print -quit) + if [ -z "$PROJECT" ]; then + echo "Generated Xcode project not found" >&2 + exit 1 + fi + + SCHEME=$(xcodebuild -project "$PROJECT" -list -json | python3 -c ' + import json, sys + data = json.load(sys.stdin) + schemes = data.get("project", {}).get("schemes", []) + scheme = next((value for value in schemes if value.endswith("_iOS")), None) + if not scheme: + raise SystemExit("No generated iOS scheme found") + print(scheme) + ') + + mkdir -p mobile-ci/app + xcodebuild \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -configuration release \ + -sdk iphoneos \ + -destination 'generic/platform=iOS' \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + DEVELOPMENT_TEAM= \ + ARCHS=arm64 \ + ONLY_ACTIVE_ARCH=YES \ + CONFIGURATION_BUILD_DIR="$PWD/mobile-ci/app" \ + build + + - name: Package resignable IPA + shell: bash + run: | + set -euo pipefail + APP=$(find mobile-ci/app -maxdepth 1 -type d -name '*.app' -print -quit) + if [ -z "$APP" ]; then + echo "Built iOS app not found" >&2 + exit 1 + fi + + if [ ! -d "$APP/PlugIns/IBridgeTunnelProvider.appex" ]; then + echo "Embedded IBridgeTunnelProvider.appex not found" >&2 + find "$APP" -maxdepth 3 -print + exit 1 + fi + + mkdir -p mobile-ci/Payload + cp -R "$APP" mobile-ci/Payload/iBridge.app + ( + cd mobile-ci + zip -qry iBridge-Mobile.ipa Payload + ) + test -s mobile-ci/iBridge-Mobile.ipa + + - name: Upload IPA artifact + uses: actions/upload-artifact@v7 + with: + name: iBridge-Mobile + path: mobile-ci/iBridge-Mobile.ipa + if-no-files-found: error + + - name: Attach IPA to release + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${{ github.event.release.tag_name }}" mobile-ci/iBridge-Mobile.ipa --clobber diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..3b1f759a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,40 @@ +name: Test iBridge + +on: + push: + branches: + - "*" + pull_request: + branches: + - "*" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf perl make + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install frontend dependencies + run: bun i --frozen-lockfile + + - name: Rust tests + run: cargo test --manifest-path src-tauri/Cargo.toml + + - name: Frontend build + run: bun run build diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..6598b2e1 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,50 @@ +# Third-party notices + +## StosVPN packet reflection technique + +iBridge Mobile contains an adapted packet-reflection implementation in +`src-tauri/ios/IBridgeTunnelProvider/PacketTunnelProvider.swift` based on +[SideStore/StosVPN](https://github.com/SideStore/StosVPN), specifically the +`PacketTunnelProvider.swift` implementation authored by the SideStore Team. + +iBridge is not StosVPN and does not imply endorsement by the SideStore Team. +The adapted code is used only as the local `NEPacketTunnelProvider` transport +inside iBridge Mobile. + +Source reference: SideStore/StosVPN commit +`010bc141e6c2303b218924686f830e3249c66842`. + +### StosVPN License + +Copyright (c) 2025 SideStore Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +**Attribution is clearly given** to the original project and author(s) in a +prominent place (e.g., README, About page, or documentation). + +Any derived project **must clearly state** that it is **based on** or **uses +code from** the original project. + +Redistribution, rebranding, or publishing of the **entire project** (or a +substantially similar copy) under the same or similar name or branding, +**without explicit written permission**, is **prohibited**. + +This clause is intended to prevent unauthorized re-uploads, clones, or +misrepresentation of the original work. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/mobile/iBridgeMobile/Bootstrap.swift b/mobile/iBridgeMobile/Bootstrap.swift new file mode 100644 index 00000000..986ad586 --- /dev/null +++ b/mobile/iBridgeMobile/Bootstrap.swift @@ -0,0 +1,102 @@ +import Foundation + +struct BootstrapPayload: Codable { + let schema: String + let deviceUdid: String + let deviceName: String + let deviceVersion: String + let appleId: String + let anisetteServer: String + let pairingPath: String + let secretPath: String + let createdAt: String +} + +struct AccountSecretPayload: Codable { + let schema: String + let appleId: String + let password: String +} + +struct MobileConfiguration { + let appleID: String + let anisetteServer: String + let deviceUDID: String + let deviceName: String + let deviceVersion: String + + static func load() -> MobileConfiguration? { + let defaults = UserDefaults.standard + guard + let appleID = defaults.string(forKey: "bootstrap.appleID"), + let anisetteServer = defaults.string(forKey: "bootstrap.anisetteServer"), + let deviceUDID = defaults.string(forKey: "bootstrap.deviceUDID"), + let deviceName = defaults.string(forKey: "bootstrap.deviceName"), + let deviceVersion = defaults.string(forKey: "bootstrap.deviceVersion") + else { return nil } + return MobileConfiguration( + appleID: appleID, + anisetteServer: anisetteServer, + deviceUDID: deviceUDID, + deviceName: deviceName, + deviceVersion: deviceVersion + ) + } +} + +enum BootstrapImporter { + static let pairingAccount = "devicePairing" + static let passwordAccount = "applePassword" + + static func importIfPresent() throws { + let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let directory = documents.appendingPathComponent("iBridgeBootstrap", isDirectory: true) + let payloadURL = directory.appendingPathComponent("bootstrap.json") + guard FileManager.default.fileExists(atPath: payloadURL.path) else { return } + + let payload = try JSONDecoder().decode(BootstrapPayload.self, from: Data(contentsOf: payloadURL)) + guard payload.schema == "ibridge.mobile-bootstrap/1" else { + throw NSError( + domain: "iBridgeBootstrap", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Unsupported bootstrap schema"] + ) + } + + let pairingURL = documents.appendingPathComponent(payload.pairingPath) + let secretURL = documents.appendingPathComponent(payload.secretPath) + let pairing = try Data(contentsOf: pairingURL) + let secret = try JSONDecoder().decode(AccountSecretPayload.self, from: Data(contentsOf: secretURL)) + + guard secret.schema == "ibridge.mobile-account-secret/1" else { + throw NSError( + domain: "iBridgeBootstrap", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Unsupported account secret schema"] + ) + } + guard secret.appleId.caseInsensitiveCompare(payload.appleId) == .orderedSame else { + throw NSError( + domain: "iBridgeBootstrap", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "Bootstrap account identity did not match"] + ) + } + + try KeychainStore.set(pairing, account: pairingAccount) + try KeychainStore.setString(secret.password, account: passwordAccount) + + let defaults = UserDefaults.standard + defaults.set(payload.appleId, forKey: "bootstrap.appleID") + defaults.set(payload.anisetteServer, forKey: "bootstrap.anisetteServer") + defaults.set(payload.deviceUdid, forKey: "bootstrap.deviceUDID") + defaults.set(payload.deviceName, forKey: "bootstrap.deviceName") + defaults.set(payload.deviceVersion, forKey: "bootstrap.deviceVersion") + defaults.set(payload.createdAt, forKey: "bootstrap.createdAt") + + try? FileManager.default.removeItem(at: secretURL) + try? FileManager.default.removeItem(at: pairingURL) + try? FileManager.default.removeItem(at: payloadURL) + try? FileManager.default.removeItem(at: directory) + } +} diff --git a/mobile/iBridgeMobile/BridgingHeader.h b/mobile/iBridgeMobile/BridgingHeader.h new file mode 100644 index 00000000..19f3091b --- /dev/null +++ b/mobile/iBridgeMobile/BridgingHeader.h @@ -0,0 +1,7 @@ +#ifndef IBRIDGE_CORE_H +#define IBRIDGE_CORE_H + +char *ibridge_sign_and_install(const char *request_json); +void ibridge_string_free(char *value); + +#endif diff --git a/mobile/iBridgeMobile/ContentView.swift b/mobile/iBridgeMobile/ContentView.swift new file mode 100644 index 00000000..1aae5242 --- /dev/null +++ b/mobile/iBridgeMobile/ContentView.swift @@ -0,0 +1,148 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct ContentView: View { + @ObservedObject var model: MobileModel + @State private var importingIPA = false + @State private var credential = "" + + private var ipaType: UTType { + UTType(filenameExtension: "ipa") ?? .data + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 18) { + header + accountCard + actionCard + statusCard + } + .padding(20) + } + .navigationTitle("iBridge") + .navigationBarTitleDisplayMode(.inline) + .fileImporter( + isPresented: $importingIPA, + allowedContentTypes: [ipaType], + allowsMultipleSelection: false + ) { result in + guard case .success(let urls) = result, let url = urls.first else { return } + Task { await model.installImportedIPA(url) } + } + } + } + + private var header: some View { + VStack(spacing: 8) { + Image(systemName: "point.3.connected.trianglepath.dotted") + .font(.system(size: 44, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + Text("Firma y refresca desde este iPhone") + .font(.headline) + .multilineTextAlignment(.center) + Text("El pairing y la cuenta llegan automáticamente desde iBridge Desktop.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + + private var accountCard: some View { + VStack(alignment: .leading, spacing: 12) { + Label("Cuenta", systemImage: "person.crop.circle") + .font(.headline) + + if let configuration = model.configuration { + LabeledContent("Apple ID", value: configuration.appleID) + LabeledContent("iPhone", value: configuration.deviceName) + LabeledContent("iOS", value: configuration.deviceVersion) + } else { + Text("Instala iBridge Mobile una vez desde iBridge Desktop para completar el bootstrap.") + .foregroundStyle(.secondary) + } + + if model.needsCredential { + SecureField("Contraseña de Apple ID", text: $credential) + .textContentType(.password) + .textFieldStyle(.roundedBorder) + Button("Guardar en Keychain") { + model.saveCredential(credential) + credential = "" + } + .buttonStyle(.bordered) + } + + if model.needsTwoFactor { + TextField("Código 2FA", text: $model.verificationCode) + .keyboardType(.numberPad) + .textContentType(.oneTimeCode) + .textFieldStyle(.roundedBorder) + Button("Continuar con 2FA") { + Task { await model.submitTwoFactor() } + } + .buttonStyle(.borderedProminent) + .disabled(model.busy || model.verificationCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18)) + } + + private var actionCard: some View { + VStack(spacing: 12) { + Button { + importingIPA = true + } label: { + Label("Seleccionar IPA e instalar", systemImage: "square.and.arrow.down") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(model.busy || model.configuration == nil) + + HStack(spacing: 10) { + Button { + Task { await model.refreshAll() } + } label: { + Label("Refrescar apps", systemImage: "arrow.clockwise") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(model.busy || model.configuration == nil) + + Button { + Task { await model.refreshSelf() } + } label: { + Label("Refrescar iBridge", systemImage: "bolt.horizontal.circle") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(model.busy || model.configuration == nil) + } + } + .frame(maxWidth: .infinity) + .padding(16) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18)) + } + + private var statusCard: some View { + HStack(alignment: .top, spacing: 12) { + if model.busy { + ProgressView() + } else { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + } + Text(model.status) + .font(.footnote) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(14) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 14)) + } +} diff --git a/mobile/iBridgeMobile/CoreBridge.swift b/mobile/iBridgeMobile/CoreBridge.swift new file mode 100644 index 00000000..71df12e8 --- /dev/null +++ b/mobile/iBridgeMobile/CoreBridge.swift @@ -0,0 +1,51 @@ +import Foundation + +struct CoreRequest: Codable, Sendable { + let appleId: String + let verificationCode: String? + let anisetteServer: String + let inputIpa: String + let storageDir: String + let pairingBase64: String + let deviceAddress: String + let scopeId: UInt32? +} + +struct CoreResponse: Codable, Sendable { + let ok: Bool + let code: String + let message: String +} + +enum CoreBridgeError: LocalizedError { + case encoding + case emptyResponse + case invalidResponse(String) + + var errorDescription: String? { + switch self { + case .encoding: "No se pudo preparar la solicitud de firma." + case .emptyResponse: "El core de firma no devolvió respuesta." + case .invalidResponse(let response): "Respuesta inválida del core: \(response)" + } + } +} + +enum CoreBridge { + static func signAndInstall(_ request: CoreRequest) async throws -> CoreResponse { + try await Task.detached(priority: .userInitiated) { + let data = try JSONEncoder().encode(request) + guard let json = String(data: data, encoding: .utf8) else { throw CoreBridgeError.encoding } + + let pointer = json.withCString { ibridge_sign_and_install($0) } + guard let pointer else { throw CoreBridgeError.emptyResponse } + defer { ibridge_string_free(pointer) } + + let responseText = String(cString: pointer) + guard let responseData = responseText.data(using: .utf8) else { + throw CoreBridgeError.invalidResponse(responseText) + } + return try JSONDecoder().decode(CoreResponse.self, from: responseData) + }.value + } +} diff --git a/mobile/iBridgeMobile/DeviceAddress.swift b/mobile/iBridgeMobile/DeviceAddress.swift new file mode 100644 index 00000000..4dca94ba --- /dev/null +++ b/mobile/iBridgeMobile/DeviceAddress.swift @@ -0,0 +1,56 @@ +import Foundation +import Darwin + +enum DeviceAddress { + static func current() -> String? { + var addresses: UnsafeMutablePointer? + guard getifaddrs(&addresses) == 0, let first = addresses else { return nil } + defer { freeifaddrs(addresses) } + + var pointer: UnsafeMutablePointer? = first + while let interface = pointer?.pointee { + defer { pointer = interface.ifa_next } + + let flags = Int32(interface.ifa_flags) + guard (flags & IFF_UP) != 0 else { continue } + + // LocalDevVPN and equivalent loopback setups expose the phone as the + // peer of a point-to-point utun interface. Reading the peer instead + // of hard-coding 10.7.0.1 also survives custom/iOS 26.x subnets. + if (flags & IFF_POINTOPOINT) != 0, + let destination = interface.ifa_dstaddr, + let host = numericHost(destination), + host != "0.0.0.0", + host != "::" { + return host + } + } + + return nil + } + + private static func numericHost(_ address: UnsafePointer) -> String? { + let family = Int32(address.pointee.sa_family) + guard family == AF_INET || family == AF_INET6 else { return nil } + + var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let length: socklen_t + if family == AF_INET6 { + length = socklen_t(MemoryLayout.size) + } else { + length = socklen_t(MemoryLayout.size) + } + + let result = getnameinfo( + address, + length, + &hostname, + socklen_t(hostname.count), + nil, + 0, + NI_NUMERICHOST + ) + guard result == 0 else { return nil } + return String(cString: hostname) + } +} diff --git a/mobile/iBridgeMobile/Info.plist b/mobile/iBridgeMobile/Info.plist new file mode 100644 index 00000000..35d8318b --- /dev/null +++ b/mobile/iBridgeMobile/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDisplayName + iBridge + CFBundleName + iBridge + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + UILaunchScreen + + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + + NSLocalNetworkUsageDescription + iBridge connects to this iPhone over its local developer pairing so it can install and refresh apps. + + diff --git a/mobile/iBridgeMobile/KeychainStore.swift b/mobile/iBridgeMobile/KeychainStore.swift new file mode 100644 index 00000000..1bed4afc --- /dev/null +++ b/mobile/iBridgeMobile/KeychainStore.swift @@ -0,0 +1,42 @@ +import Foundation +import Security + +enum KeychainStore { + private static let service = "com.dannybaanks.ibridge.mobile" + + static func set(_ data: Data, account: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + SecItemDelete(query as CFDictionary) + var item = query + item[kSecValueData as String] = data + item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let status = SecItemAdd(item as CFDictionary, nil) + guard status == errSecSuccess else { throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) } + } + + static func data(account: String) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { return nil } + return result as? Data + } + + static func setString(_ value: String, account: String) throws { + try set(Data(value.utf8), account: account) + } + + static func string(account: String) -> String? { + guard let data = data(account: account) else { return nil } + return String(data: data, encoding: .utf8) + } +} diff --git a/mobile/iBridgeMobile/MobileModel.swift b/mobile/iBridgeMobile/MobileModel.swift new file mode 100644 index 00000000..a6aa09c8 --- /dev/null +++ b/mobile/iBridgeMobile/MobileModel.swift @@ -0,0 +1,169 @@ +import Foundation +import Combine + +@MainActor +final class MobileModel: ObservableObject { + @Published private(set) var configuration: MobileConfiguration? + @Published private(set) var busy = false + @Published var status = "Preparando iBridge…" + @Published var needsCredential = false + @Published var needsTwoFactor = false + @Published var verificationCode = "" + + private var pendingIPA: URL? + private let selfReleaseURL = URL(string: "https://github.com/DannyBaanks/iBridge/releases/latest/download/iBridge-Mobile.ipa")! + + init() { + do { + try BootstrapImporter.importIfPresent() + configuration = MobileConfiguration.load() + needsCredential = KeychainStore.string(account: BootstrapImporter.passwordAccount) == nil + status = configuration == nil + ? "Instala iBridge desde iBridge Desktop una vez para completar el pairing." + : "Listo para firmar." + } catch { + status = "Bootstrap falló: \(error.localizedDescription)" + } + } + + func saveCredential(_ value: String) { + guard !value.isEmpty else { + status = "Escribe la credencial de tu Apple ID." + return + } + do { + try KeychainStore.setString(value, account: BootstrapImporter.passwordAccount) + needsCredential = false + status = "Cuenta guardada en Keychain." + } catch { + status = "Keychain falló: \(error.localizedDescription)" + } + } + + func installImportedIPA(_ source: URL) async { + guard let copied = copyIntoManagedStorage(source) else { return } + await signAndInstall(copied, verificationCode: nil) + } + + func submitTwoFactor() async { + guard let pendingIPA else { return } + let code = verificationCode.trimmingCharacters(in: .whitespacesAndNewlines) + guard !code.isEmpty else { return } + needsTwoFactor = false + await signAndInstall(pendingIPA, verificationCode: code) + } + + func refreshAll() async { + guard !busy else { return } + let paths = UserDefaults.standard.stringArray(forKey: "managedIPAPaths") ?? [] + for path in paths { + let url = URL(fileURLWithPath: path) + guard FileManager.default.fileExists(atPath: path) else { continue } + await signAndInstall(url, verificationCode: nil) + if needsTwoFactor { return } + } + await refreshSelf() + } + + func refreshSelf() async { + guard !busy else { return } + do { + status = "Descargando iBridge Mobile…" + let (downloaded, _) = try await URLSession.shared.download(from: selfReleaseURL) + let cache = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appendingPathComponent("iBridge-Mobile.ipa") + try? FileManager.default.removeItem(at: cache) + try FileManager.default.moveItem(at: downloaded, to: cache) + await signAndInstall(cache, verificationCode: nil) + } catch { + status = "No pude descargar iBridge Mobile: \(error.localizedDescription)" + } + } + + private func signAndInstall(_ ipa: URL, verificationCode: String?) async { + guard let configuration else { + status = "Falta el bootstrap del iPhone." + return + } + guard KeychainStore.string(account: BootstrapImporter.passwordAccount) != nil else { + needsCredential = true + status = "Vuelve a conectar tu Apple ID." + return + } + guard let pairing = KeychainStore.data(account: BootstrapImporter.pairingAccount) else { + status = "Falta el pairing. Reinstala iBridge desde Desktop." + return + } + guard let address = DeviceAddress.current() else { + status = "Activa LocalDevVPN para que iBridge pueda hablar con este iPhone." + return + } + + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("iBridge", isDirectory: true) + do { + try FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) + } catch { + status = "No pude crear el almacenamiento de firma: \(error.localizedDescription)" + return + } + + busy = true + pendingIPA = ipa + status = "Firmando e instalando \(ipa.lastPathComponent)…" + defer { busy = false } + + do { + let response = try await CoreBridge.signAndInstall(CoreRequest( + appleId: configuration.appleID, + verificationCode: verificationCode, + anisetteServer: configuration.anisetteServer, + inputIpa: ipa.path, + storageDir: appSupport.path, + pairingBase64: pairing.base64EncodedString(), + deviceAddress: address, + scopeId: nil + )) + + if response.ok { + pendingIPA = nil + self.verificationCode = "" + needsTwoFactor = false + status = "Firmada e instalada ✓" + } else if response.code == "two_factor_required" { + needsTwoFactor = true + status = "Apple pidió el código 2FA." + } else if response.code == "credential_missing" { + needsCredential = true + status = "Vuelve a conectar tu Apple ID." + } else { + status = "\(response.code): \(response.message)" + } + } catch { + status = "Falló la operación: \(error.localizedDescription)" + } + } + + private func copyIntoManagedStorage(_ source: URL) -> URL? { + let accessing = source.startAccessingSecurityScopedResource() + defer { if accessing { source.stopAccessingSecurityScopedResource() } } + + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + let directory = appSupport.appendingPathComponent("ManagedIPAs", isDirectory: true) + let destination = directory.appendingPathComponent(source.lastPathComponent) + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try? FileManager.default.removeItem(at: destination) + try FileManager.default.copyItem(at: source, to: destination) + var paths = UserDefaults.standard.stringArray(forKey: "managedIPAPaths") ?? [] + if !paths.contains(destination.path) { + paths.append(destination.path) + UserDefaults.standard.set(paths, forKey: "managedIPAPaths") + } + return destination + } catch { + status = "No pude importar la IPA: \(error.localizedDescription)" + return nil + } + } +} diff --git a/mobile/iBridgeMobile/iBridgeMobileApp.swift b/mobile/iBridgeMobile/iBridgeMobileApp.swift new file mode 100644 index 00000000..e6c04ac6 --- /dev/null +++ b/mobile/iBridgeMobile/iBridgeMobileApp.swift @@ -0,0 +1,12 @@ +import SwiftUI + +@main +struct iBridgeMobileApp: App { + @StateObject private var model = MobileModel() + + var body: some Scene { + WindowGroup { + ContentView(model: model) + } + } +} diff --git a/mobile/ibridge-core/Cargo.toml b/mobile/ibridge-core/Cargo.toml new file mode 100644 index 00000000..3efe1acc --- /dev/null +++ b/mobile/ibridge-core/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "ibridge-core" +version = "0.1.0" +edition = "2024" +license = "MIT" +publish = false + +[lib] +name = "ibridge_core" +crate-type = ["staticlib"] + +[dependencies] +base64 = "0.22.1" +futures = "0.3.32" +idevice = { version = "0.1.61", default-features = false, features = ["ring", "afc", "installation_proxy", "remote_pairing", "rsd", "tcp"] } +isideload = { git = "https://github.com/nab138/isideload", rev = "f6a4d5dba717d72fc2af63eaba26b27ba44116be", package = "isideload", default-features = false, features = ["install", "tokio-mt"] } +keyring = { version = "3.6.3", default-features = false, features = ["apple-native"] } +rootcause = "0.12.0" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1.49", features = ["rt-multi-thread", "time", "net", "sync"] } diff --git a/mobile/ibridge-core/src/lib.rs b/mobile/ibridge-core/src/lib.rs new file mode 100644 index 00000000..c1d3836b --- /dev/null +++ b/mobile/ibridge-core/src/lib.rs @@ -0,0 +1,282 @@ +use std::{ + ffi::{CStr, CString}, + net::IpAddr, + os::raw::c_char, + path::{Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use futures::FutureExt; +use idevice::{pairing_file::PairingFile, provider::TcpProvider}; +use isideload::{ + anisette::remote_v3::RemoteV3AnisetteProvider, + auth::apple_account::{AppleAccount, TwoFactorCallbackResponse}, + dev::developer_session::DeveloperSession, + sideload::{SideloaderBuilder, builder::MaxCertsBehavior, install::install_app}, + util::storage::SideloadingStorage, +}; +use rootcause::prelude::*; +use serde::{Deserialize, Serialize}; + +const KEYCHAIN_SERVICE: &str = "com.dannybaanks.ibridge.mobile"; +const KEYCHAIN_ACCOUNT: &str = "applePassword"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SignInstallRequest { + apple_id: String, + verification_code: Option, + anisette_server: String, + input_ipa: String, + storage_dir: String, + pairing_base64: String, + device_address: String, + scope_id: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct CoreResponse { + ok: bool, + code: String, + message: String, +} + +#[derive(Debug)] +struct CoreFailure { + code: &'static str, + message: String, +} + +impl CoreFailure { + fn new(code: &'static str, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } +} + +#[derive(Debug)] +struct DirectoryStorage { + root: PathBuf, +} + +impl DirectoryStorage { + fn new(root: impl AsRef) -> Result { + let root = root.as_ref().to_path_buf(); + std::fs::create_dir_all(&root).map_err(|error| { + CoreFailure::new( + "storage_error", + format!("Unable to create signing storage: {error}"), + ) + })?; + Ok(Self { root }) + } + + fn path_for_key(&self, key: &str) -> PathBuf { + let encoded = key + .as_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + self.root.join(encoded) + } +} + +impl SideloadingStorage for DirectoryStorage { + fn store(&self, key: &str, value: &str) -> Result<(), Report> { + std::fs::create_dir_all(&self.root).context("create iBridge signing storage")?; + std::fs::write(self.path_for_key(key), value).context("write iBridge signing state")?; + Ok(()) + } + + fn retrieve(&self, key: &str) -> Result, Report> { + match std::fs::read_to_string(self.path_for_key(key)) { + Ok(value) => Ok(Some(value)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).context("read iBridge signing state"), + } + } + + fn delete(&self, key: &str) -> Result<(), Report> { + match std::fs::remove_file(self.path_for_key(key)) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).context("delete iBridge signing state"), + } + } +} + +async fn sign_and_install(request: SignInstallRequest) -> Result<(), CoreFailure> { + let _ = isideload::init(); + + let credential = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) + .map_err(|error| CoreFailure::new("keychain_error", error.to_string()))? + .get_password() + .map_err(|error| CoreFailure::new("credential_missing", error.to_string()))?; + + let storage_root = PathBuf::from(&request.storage_dir); + let tfa_requested = Arc::new(AtomicBool::new(false)); + let tfa_requested_for_callback = Arc::clone(&tfa_requested); + let verification_code = request.verification_code.clone(); + + let tfa_callback = move |_params| { + let code = verification_code.clone(); + let tfa_requested = Arc::clone(&tfa_requested_for_callback); + async move { + tfa_requested.store(true, Ordering::SeqCst); + Ok(match code { + Some(code) if !code.trim().is_empty() => { + TwoFactorCallbackResponse::SubmitCode(code.trim().to_string()) + } + _ => TwoFactorCallbackResponse::Abort, + }) + } + .boxed() + }; + + let anisette_url = if request.anisette_server.starts_with("http") { + request.anisette_server.clone() + } else { + format!("https://{}", request.anisette_server) + }; + + let anisette_storage = DirectoryStorage::new(storage_root.join("anisette"))?; + let anisette = RemoteV3AnisetteProvider::default() + .map_err(|error| CoreFailure::new("anisette_error", error.to_string()))? + .set_serial_number("0".to_string()) + .set_storage(Box::new(anisette_storage)) + .set_url(&anisette_url); + + let login_result = AppleAccount::builder(&request.apple_id.to_lowercase()) + .anisette_provider(anisette) + .login(&credential, Box::new(tfa_callback)) + .await; + + let mut account = match login_result { + Ok(account) => account, + Err(error) + if tfa_requested.load(Ordering::SeqCst) + && request.verification_code.as_deref().unwrap_or("").is_empty() => + { + return Err(CoreFailure::new( + "two_factor_required", + "Apple requested a verification code", + )); + } + Err(error) => { + return Err(CoreFailure::new("apple_login_failed", error.to_string())); + } + }; + + let developer_session = DeveloperSession::from_account(&mut account) + .await + .map_err(|error| CoreFailure::new("developer_session_failed", error.to_string()))?; + + let signing_storage = DirectoryStorage::new(storage_root.join("signing"))?; + let mut sideloader = SideloaderBuilder::new(developer_session, request.apple_id.to_lowercase()) + .machine_name("iBridge Mobile".into()) + .storage(Box::new(signing_storage)) + .max_certs_behavior(MaxCertsBehavior::Error) + .build(); + + let (signed_app_path, _) = sideloader + .sign_app( + PathBuf::from(&request.input_ipa), + None, + false, + None:: std::future::Ready<()>>, + ) + .await + .map_err(|error| CoreFailure::new("sign_failed", error.to_string()))?; + + let pairing_bytes = BASE64_STANDARD + .decode(request.pairing_base64.as_bytes()) + .map_err(|error| CoreFailure::new("pairing_decode_failed", error.to_string()))?; + let pairing_file = PairingFile::from_bytes(&pairing_bytes) + .map_err(|error| CoreFailure::new("pairing_invalid", error.to_string()))?; + let device_address: IpAddr = request + .device_address + .parse() + .map_err(|error| CoreFailure::new("device_address_invalid", format!("{error}")))?; + + let provider = TcpProvider { + addr: device_address, + scope_id: request.scope_id, + pairing_file, + label: "iBridge Mobile self-host".to_string(), + }; + + install_app(&provider, &signed_app_path, |_| {}) + .await + .map_err(|error| CoreFailure::new("install_failed", error.to_string()))?; + + Ok(()) +} + +fn response_json(response: CoreResponse) -> *mut c_char { + let json = serde_json::to_string(&response).unwrap_or_else(|_| { + "{\"ok\":false,\"code\":\"serialization_error\",\"message\":\"Unable to encode response\"}" + .to_string() + }); + CString::new(json).unwrap().into_raw() +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ibridge_sign_and_install(request_json: *const c_char) -> *mut c_char { + if request_json.is_null() { + return response_json(CoreResponse { + ok: false, + code: "invalid_request".into(), + message: "Request pointer was null".into(), + }); + } + + let request_json = unsafe { CStr::from_ptr(request_json) }; + let request: SignInstallRequest = match serde_json::from_slice(request_json.to_bytes()) { + Ok(request) => request, + Err(error) => { + return response_json(CoreResponse { + ok: false, + code: "invalid_request".into(), + message: error.to_string(), + }); + } + }; + + let runtime = match tokio::runtime::Builder::new_multi_thread().enable_all().build() { + Ok(runtime) => runtime, + Err(error) => { + return response_json(CoreResponse { + ok: false, + code: "runtime_failed".into(), + message: error.to_string(), + }); + } + }; + + match runtime.block_on(sign_and_install(request)) { + Ok(()) => response_json(CoreResponse { + ok: true, + code: "ok".into(), + message: "Signed and installed".into(), + }), + Err(error) => response_json(CoreResponse { + ok: false, + code: error.code.into(), + message: error.message, + }), + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ibridge_string_free(value: *mut c_char) { + if !value.is_null() { + let _ = unsafe { CString::from_raw(value) }; + } +} diff --git a/mobile/project.yml b/mobile/project.yml new file mode 100644 index 00000000..5726db02 --- /dev/null +++ b/mobile/project.yml @@ -0,0 +1,27 @@ +name: iBridgeMobile +options: + bundleIdPrefix: com.dannybaanks.ibridge + deploymentTarget: + iOS: "17.0" +settings: + base: + SWIFT_VERSION: "6.0" + IPHONEOS_DEPLOYMENT_TARGET: "17.0" +targets: + iBridgeMobile: + type: application + platform: iOS + sources: + - path: iBridgeMobile + info: + path: iBridgeMobile/Info.plist + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.dannybaanks.ibridge.mobile + PRODUCT_NAME: iBridge + SWIFT_OBJC_BRIDGING_HEADER: iBridgeMobile/BridgingHeader.h + LIBRARY_SEARCH_PATHS: "$(inherited) $(SRCROOT)/build" + OTHER_LDFLAGS: "$(inherited) -libridge_core -framework Security -framework SystemConfiguration" + CODE_SIGN_STYLE: Automatic + scheme: + testTargets: [] diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e6790abb..8ee9c4e4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2618,7 +2618,7 @@ dependencies = [ [[package]] name = "iloader" -version = "2.3.2" +version = "2.3.3" dependencies = [ "chrono", "futures", @@ -2637,6 +2637,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-ibridge-tunnel", "tauri-plugin-opener", "tauri-plugin-process", "tauri-plugin-store", @@ -5737,6 +5738,15 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-ibridge-tunnel" +version = "0.1.0" +dependencies = [ + "serde", + "tauri", + "tauri-plugin", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.5" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 634114c4..a5cf4b5d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -44,5 +44,8 @@ rootcause = "0.12.1" thiserror = "2.0.18" strum = {version = "0.28.0", features = ["derive"] } +[target.'cfg(target_os = "ios")'.dependencies] +tauri-plugin-ibridge-tunnel = { path = "plugins/ibridge-tunnel" } + [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" diff --git a/src-tauri/ios/IBridgeTunnelProvider/IBridgeTunnelProvider.entitlements b/src-tauri/ios/IBridgeTunnelProvider/IBridgeTunnelProvider.entitlements new file mode 100644 index 00000000..afa5a4d7 --- /dev/null +++ b/src-tauri/ios/IBridgeTunnelProvider/IBridgeTunnelProvider.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + + diff --git a/src-tauri/ios/IBridgeTunnelProvider/PacketTunnelProvider.swift b/src-tauri/ios/IBridgeTunnelProvider/PacketTunnelProvider.swift new file mode 100644 index 00000000..f496a697 --- /dev/null +++ b/src-tauri/ios/IBridgeTunnelProvider/PacketTunnelProvider.swift @@ -0,0 +1,98 @@ +// iBridge local packet reflector. +// +// Based on the packet-reflection technique from SideStore/StosVPN: +// https://github.com/SideStore/StosVPN +// Copyright (c) 2025 SideStore Team. +// See THIRD_PARTY_NOTICES.md for license and attribution. + +import NetworkExtension + +final class PacketTunnelProvider: NEPacketTunnelProvider { + private var tunnelDeviceIP = "10.7.0.0" + private var tunnelFakeIP = "10.7.0.1" + private let tunnelSubnetMask = "255.255.255.0" + + private var deviceIPValue: UInt32 = 0 + private var fakeIPValue: UInt32 = 0 + + override func startTunnel( + options: [String: NSObject]?, + completionHandler: @escaping (Error?) -> Void + ) { + if let deviceIP = options?["TunnelDeviceIP"] as? String { + tunnelDeviceIP = deviceIP + } + if let fakeIP = options?["TunnelFakeIP"] as? String { + tunnelFakeIP = fakeIP + } + + deviceIPValue = ipToUInt32(tunnelDeviceIP) + fakeIPValue = ipToUInt32(tunnelFakeIP) + + let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: tunnelDeviceIP) + let ipv4 = NEIPv4Settings( + addresses: [tunnelDeviceIP], + subnetMasks: [tunnelSubnetMask] + ) + ipv4.includedRoutes = [ + NEIPv4Route(destinationAddress: tunnelDeviceIP, subnetMask: tunnelSubnetMask) + ] + ipv4.excludedRoutes = [.default()] + settings.ipv4Settings = ipv4 + + setTunnelNetworkSettings(settings) { [weak self] error in + guard error == nil else { + completionHandler(error) + return + } + self?.pumpPackets() + completionHandler(nil) + } + } + + override func stopTunnel( + with reason: NEProviderStopReason, + completionHandler: @escaping () -> Void + ) { + completionHandler() + } + + private func pumpPackets() { + packetFlow.readPackets { [weak self] packets, protocols in + guard let self else { return } + + var modified = packets + for index in modified.indices + where protocols[index].int32Value == AF_INET && modified[index].count >= 20 { + modified[index].withUnsafeMutableBytes { bytes in + guard let words = bytes.baseAddress?.assumingMemoryBound(to: UInt32.self) else { + return + } + let source = UInt32(bigEndian: words[3]) + let destination = UInt32(bigEndian: words[4]) + if source == self.deviceIPValue { + words[3] = self.fakeIPValue.bigEndian + } + if destination == self.fakeIPValue { + words[4] = self.deviceIPValue.bigEndian + } + } + } + + self.packetFlow.writePackets(modified, withProtocols: protocols) + self.pumpPackets() + } + } + + private func ipToUInt32(_ address: String) -> UInt32 { + let parts = address.split(separator: ".") + guard parts.count == 4, + let a = UInt32(parts[0]), + let b = UInt32(parts[1]), + let c = UInt32(parts[2]), + let d = UInt32(parts[3]) else { + return 0 + } + return (a << 24) | (b << 16) | (c << 8) | d + } +} diff --git a/src-tauri/ios/iBridge.entitlements b/src-tauri/ios/iBridge.entitlements new file mode 100644 index 00000000..afa5a4d7 --- /dev/null +++ b/src-tauri/ios/iBridge.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + + diff --git a/src-tauri/ios/project.yml.hbs b/src-tauri/ios/project.yml.hbs new file mode 100644 index 00000000..fe06483e --- /dev/null +++ b/src-tauri/ios/project.yml.hbs @@ -0,0 +1,199 @@ +name: {{app.name}} +options: + bundleIdPrefix: {{app.identifier}} + deploymentTarget: + iOS: {{apple.ios-version}} + macOS: {{apple.macos-version}} +fileGroups: [{{join file-groups}}] +configs: + debug: debug + release: release +settingGroups: + app: + base: + PRODUCT_NAME: {{app.stylized-name}} + PRODUCT_BUNDLE_IDENTIFIER: {{app.identifier}} + {{#if apple.development-team}} + DEVELOPMENT_TEAM: {{apple.development-team}} + {{/if}} +targetTemplates: + app: + type: application + sources: + - path: Sources + scheme: + environmentVariables: + RUST_BACKTRACE: full + RUST_LOG: info + settings: + groups: [app] +targets: + {{app.name}}_iOS: + type: application + platform: iOS + sources: + - path: Sources + - path: {{app.asset-dir}} + buildPhase: resources + type: folder + {{#if asset-catalogs}}{{~#each asset-catalogs}} + - {{prefix-path this}}{{/each}}{{/if}} + {{#if ios-additional-targets}}{{~#each ios-additional-targets}} + - path: {{prefix-path this}}{{/each}}{{/if}} + info: + path: {{app.name}}_iOS/Info.plist + properties: + LSRequiresIPhoneOS: true + UILaunchStoryboardName: LaunchScreen + UIRequiredDeviceCapabilities: [arm64, metal] + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + CFBundleShortVersionString: {{apple.bundle-version-short}} + CFBundleVersion: {{apple.bundle-version}} + {{#if apple.plist-pairs}}{{~#each apple.plist-pairs}} + {{this.key}}: {{this.value}}{{/each}}{{/if}} + scheme: + environmentVariables: + RUST_BACKTRACE: full + RUST_LOG: info + {{~#if ios-command-line-arguments}} + commandLineArguments: + {{~#each ios-command-line-arguments}} + "{{this}}": true + {{/each}}{{~/if}} + settings: + base: + ENABLE_BITCODE: {{apple.enable-bitcode}} + ARCHS: [{{join ios-valid-archs}}] + VALID_ARCHS: {{#if ios-valid-archs}}{{~#each ios-valid-archs}} {{this}} {{/each}}{{/if}} + LIBRARY_SEARCH_PATHS[sdk=iphoneos*]: $(inherited) $(PROJECT_DIR)/Sources/aarch64-apple-ios/$(CONFIGURATION) + LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]: $(inherited) "{{prefix-path "target/"}}{{ios-sim-arch}}/$(CONFIGURATION)" + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true + CODE_SIGN_ENTITLEMENTS: {{prefix-path "ios/iBridge.entitlements"}} + groups: [app] + dependencies: + - framework: lib{{#if app.lib-name}}{{app.lib-name}}{{else}}{{snake-case app.name}}{{/if}}.a + embed: false + {{#if ios-libraries}}{{~#each ios-libraries}} + - framework: {{this}} + embed: false{{/each}}{{/if}} + {{#if ios-vendor-frameworks}}{{~#each ios-vendor-frameworks}} + - framework: {{prefix-path this}}{{/each}}{{/if}} + {{#if ios-vendor-sdks}}{{~#each ios-vendor-sdks}} + - sdk: {{prefix-path this}}{{/each}}{{/if}} + - sdk: CoreGraphics.framework + - sdk: Metal.framework + - sdk: MetalKit.framework + - sdk: QuartzCore.framework + - sdk: Security.framework + - sdk: UIKit.framework + {{#if ios-frameworks}}{{~#each ios-frameworks}} + - sdk: {{this}}.framework{{/each}}{{/if}} + - target: IBridgeTunnelProvider + embed: true + preBuildScripts: + {{#if ios-pre-build-scripts}}{{~#each ios-pre-build-scripts}}{{#if this.path}} + - path: {{this.path}}{{/if}}{{#if this.script}} + - script: {{this.script}}{{/if}}{{#if this.name}} + name: {{this.name}}{{/if}}{{#if this.input-files}} + inputFiles: {{~#each this.input-files}} + - {{this}}{{/each}}{{/if}}{{#if this.output-files}} + outputFiles: {{~#each this.output-files}} + - {{this}}{{/each}}{{/if}}{{#if this.input-file-lists}} + inputFileLists: {{~#each this.output-files}} + - {{this}}{{/each}}{{/if}}{{#if this.output-file-lists}} + outputFileLists: {{~#each this.output-files}} + - {{this}}{{/each}}{{/if}}{{#if this.shell}} + shell: {{this.shell}}{{/if}}{{#if this.show-env-vars}} + showEnvVars: {{this.show_env_vars}}{{/if}}{{#if this.run-only-when-installing}} + runOnlyWhenInstalling: {{this.run-only-when-installing}}{{/if}}{{#if this.based-on-dependency-analysis}} + basedOnDependencyAnalysis: {{this.based-on-dependency-analysis}}{{/if}}{{#if this.discovered-dependency-file}} + discoveredDependencyFile: {{this.discovered-dependency-file}}{{/if}} + {{~/each}}{{/if}} + - script: |- + PATH=${PATH}:${HOME:?}/.cargo/bin + CARGO_APPLE=`which cargo-apple` + ${CARGO_APPLE} xcode-script -v --platform "${PLATFORM_DISPLAY_NAME:?}" --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:?}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?} ${FEATURES} + name: Build Rust Code + basedOnDependencyAnalysis: false + outputFiles: + - $(SRCROOT)/target/aarch64-apple-ios/${CONFIGURATION}/deps/lib{{#if app.lib-name}}{{app.lib-name}}{{else}}{{snake-case app.name}}{{/if}}.a + - $(SRCROOT)/target/x86_64-apple-ios/${CONFIGURATION}/deps/lib{{#if app.lib-name}}{{app.lib-name}}{{else}}{{snake-case app.name}}{{/if}}.a + {{~#if ios-post-compile-scripts}} + postCompileScripts: + {{~#each ios-post-compile-scripts}}{{#if this.path}} + - path: {{this.path}}{{/if}}{{#if this.script}} + - script: {{this.script}}{{/if}}{{#if this.name}} + name: {{this.name}}{{/if}}{{#if this.input-files}} + inputFiles: {{~#each this.input-files}} + - {{this}}{{/each}}{{/if}}{{#if this.output-files}} + outputFiles: {{~#each this.output-files}} + - {{this}}{{/each}}{{/if}}{{#if this.input-file-lists}} + inputFileLists: {{~#each this.output-files}} + - {{this}}{{/each}}{{/if}}{{#if this.output-file-lists}} + outputFileLists: {{~#each this.output-files}} + - {{this}}{{/each}}{{/if}}{{#if this.shell}} + shell: {{this.shell}}{{/if}}{{#if this.show-env-vars}} + showEnvVars: {{this.show_env_vars}}{{/if}}{{#if this.run-only-when-installing}} + runOnlyWhenInstalling: {{this.run-only-when-installing}}{{/if}}{{#if this.based-on-dependency-analysis}} + basedOnDependencyAnalysis: {{this.based-on-dependency-analysis}}{{/if}}{{#if this.discovered-dependency-file}} + discoveredDependencyFile: {{this.discovered-dependency-file}}{{/if}} + {{~/each~}} + {{~/if~}} + {{~#if ios-post-build-scripts}} + postBuildScripts: + {{~#each ios-post-build-scripts}}{{#if this.path}} + - path: {{this.path}}{{/if}}{{#if this.script}} + - script: {{this.script}}{{/if}}{{#if this.name}} + name: {{this.name}}{{/if}}{{#if this.input-files}} + inputFiles: {{~#each this.input-files}} + - {{this}}{{/each}}{{/if}}{{#if this.output-files}} + outputFiles: {{~#each this.output-files}} + - {{this}}{{/each}}{{/if}}{{#if this.input-file-lists}} + inputFileLists: {{~#each this.output-files}} + - {{this}}{{/each}}{{/if}}{{#if this.output-file-lists}} + outputFileLists: {{~#each this.output-files}} + - {{this}}{{/each}}{{/if}}{{#if this.shell}} + shell: {{this.shell}}{{/if}}{{#if this.show-env-vars}} + showEnvVars: {{this.show_env_vars}}{{/if}}{{#if this.run-only-when-installing}} + runOnlyWhenInstalling: {{this.run-only-when-installing}}{{/if}}{{#if this.based-on-dependency-analysis}} + basedOnDependencyAnalysis: {{this.based-on-dependency-analysis}}{{/if}}{{#if this.discovered-dependency-file}} + discoveredDependencyFile: {{this.discovered-dependency-file}}{{/if}} + {{~/each~}} + {{~/if}} + + IBridgeTunnelProvider: + type: app-extension + platform: iOS + deploymentTarget: {{apple.ios-version}} + sources: + - path: {{prefix-path "ios/IBridgeTunnelProvider"}} + info: + path: IBridgeTunnelProvider/Info.plist + properties: + CFBundleDisplayName: iBridge Tunnel + CFBundleShortVersionString: {{apple.bundle-version-short}} + CFBundleVersion: {{apple.bundle-version}} + NSExtension: + NSExtensionPointIdentifier: com.apple.networkextension.packet-tunnel + NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).PacketTunnelProvider + settings: + base: + PRODUCT_NAME: iBridgeTunnelProvider + PRODUCT_BUNDLE_IDENTIFIER: com.dannybaanks.ibridge.mobile.tunnel + CODE_SIGN_ENTITLEMENTS: {{prefix-path "ios/IBridgeTunnelProvider/IBridgeTunnelProvider.entitlements"}} + APPLICATION_EXTENSION_API_ONLY: true + SKIP_INSTALL: true + TARGETED_DEVICE_FAMILY: "1,2" + {{#if apple.development-team}} + DEVELOPMENT_TEAM: {{apple.development-team}} + {{/if}} + dependencies: + - sdk: NetworkExtension.framework diff --git a/src-tauri/plugins/ibridge-tunnel/Cargo.toml b/src-tauri/plugins/ibridge-tunnel/Cargo.toml new file mode 100644 index 00000000..d783f0e6 --- /dev/null +++ b/src-tauri/plugins/ibridge-tunnel/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "tauri-plugin-ibridge-tunnel" +version = "0.1.0" +edition = "2024" +links = "tauri-plugin-ibridge-tunnel" + +[build-dependencies] +tauri-plugin = { version = "2", features = ["build"] } + +[dependencies] +serde = { version = "1", features = ["derive"] } +tauri = "2" diff --git a/src-tauri/plugins/ibridge-tunnel/build.rs b/src-tauri/plugins/ibridge-tunnel/build.rs new file mode 100644 index 00000000..ad00b326 --- /dev/null +++ b/src-tauri/plugins/ibridge-tunnel/build.rs @@ -0,0 +1,8 @@ +const COMMANDS: &[&str] = &["start", "status", "stop"]; + +fn main() { + tauri_plugin::Builder::new(COMMANDS) + .ios_path("ios") + .try_build() + .expect("failed to build iBridge tunnel plugin"); +} diff --git a/src-tauri/plugins/ibridge-tunnel/ios/Package.swift b/src-tauri/plugins/ibridge-tunnel/ios/Package.swift new file mode 100644 index 00000000..a5929efb --- /dev/null +++ b/src-tauri/plugins/ibridge-tunnel/ios/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "tauri-plugin-ibridge-tunnel", + platforms: [.iOS(.v17)], + products: [ + .library( + name: "tauri-plugin-ibridge-tunnel", + type: .static, + targets: ["tauri-plugin-ibridge-tunnel"]) + ], + dependencies: [ + .package(name: "Tauri", path: "../.tauri/tauri-api") + ], + targets: [ + .target( + name: "tauri-plugin-ibridge-tunnel", + dependencies: [.byName(name: "Tauri")], + path: "Sources") + ] +) diff --git a/src-tauri/plugins/ibridge-tunnel/ios/Sources/IBridgeTunnelPlugin.swift b/src-tauri/plugins/ibridge-tunnel/ios/Sources/IBridgeTunnelPlugin.swift new file mode 100644 index 00000000..4c6162ef --- /dev/null +++ b/src-tauri/plugins/ibridge-tunnel/ios/Sources/IBridgeTunnelPlugin.swift @@ -0,0 +1,114 @@ +import NetworkExtension +import Tauri + +private let providerBundleIdentifier = "com.dannybaanks.ibridge.mobile.tunnel" +private let tunnelDeviceIP = "10.7.0.0" +private let tunnelFakeIP = "10.7.0.1" + +private func statusName(_ status: NEVPNStatus) -> String { + switch status { + case .invalid: return "invalid" + case .disconnected: return "disconnected" + case .connecting: return "connecting" + case .connected: return "connected" + case .reasserting: return "reasserting" + case .disconnecting: return "disconnecting" + @unknown default: return "unknown" + } +} + +private func response(_ manager: NETunnelProviderManager?) -> [String: Any] { + let status = manager?.connection.status ?? .invalid + return [ + "connected": status == .connected, + "status": statusName(status), + ] +} + +private func loadManager(_ completion: @escaping (Result) -> Void) { + NETunnelProviderManager.loadAllFromPreferences { managers, error in + if let error { + completion(.failure(error)) + return + } + + if let existing = managers?.first(where: { + ($0.protocolConfiguration as? NETunnelProviderProtocol)?.providerBundleIdentifier + == providerBundleIdentifier + }) { + completion(.success(existing)) + return + } + + let manager = NETunnelProviderManager() + let configuration = NETunnelProviderProtocol() + configuration.providerBundleIdentifier = providerBundleIdentifier + configuration.serverAddress = "iBridge Local Tunnel" + manager.protocolConfiguration = configuration + manager.localizedDescription = "iBridge Local Tunnel" + manager.isEnabled = true + + manager.saveToPreferences { saveError in + if let saveError { + completion(.failure(saveError)) + return + } + manager.loadFromPreferences { loadError in + if let loadError { + completion(.failure(loadError)) + } else { + completion(.success(manager)) + } + } + } + } +} + +final class IBridgeTunnelPlugin: Plugin { + @objc public func start(_ invoke: Invoke) throws { + loadManager { result in + switch result { + case .failure(let error): + invoke.reject(error.localizedDescription) + case .success(let manager): + do { + try manager.connection.startVPNTunnel(options: [ + "TunnelDeviceIP": tunnelDeviceIP as NSString, + "TunnelFakeIP": tunnelFakeIP as NSString, + ]) + invoke.resolve(response(manager)) + } catch { + invoke.reject(error.localizedDescription) + } + } + } + } + + @objc public func status(_ invoke: Invoke) throws { + loadManager { result in + switch result { + case .failure(let error): + invoke.reject(error.localizedDescription) + case .success(let manager): + invoke.resolve(response(manager)) + } + } + } + + @objc public func stop(_ invoke: Invoke) throws { + loadManager { result in + switch result { + case .failure(let error): + invoke.reject(error.localizedDescription) + case .success(let manager): + manager.connection.stopVPNTunnel() + invoke.resolve(response(manager)) + } + } + } +} + +@_cdecl("init_plugin_ibridge_tunnel") +func initPlugin() -> Plugin { + IBridgeTunnelPlugin() +} diff --git a/src-tauri/plugins/ibridge-tunnel/src/lib.rs b/src-tauri/plugins/ibridge-tunnel/src/lib.rs new file mode 100644 index 00000000..68b6c5bd --- /dev/null +++ b/src-tauri/plugins/ibridge-tunnel/src/lib.rs @@ -0,0 +1,52 @@ +#![cfg(target_os = "ios")] + +use serde::Deserialize; +use tauri::{ + Manager, Runtime, + plugin::{Builder, PluginHandle, TauriPlugin}, +}; + +tauri::ios_plugin_binding!(init_plugin_ibridge_tunnel); + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TunnelStatus { + pub connected: bool, + pub status: String, +} + +pub struct IBridgeTunnel(PluginHandle); + +impl IBridgeTunnel { + pub fn start(&self) -> Result { + self.0.run_mobile_plugin("start", ()) + } + + pub fn status(&self) -> Result { + self.0.run_mobile_plugin("status", ()) + } + + pub fn stop(&self) -> Result { + self.0.run_mobile_plugin("stop", ()) + } +} + +pub trait IBridgeTunnelExt { + fn ibridge_tunnel(&self) -> &IBridgeTunnel; +} + +impl> IBridgeTunnelExt for T { + fn ibridge_tunnel(&self) -> &IBridgeTunnel { + self.state::>().inner() + } +} + +pub fn init() -> TauriPlugin { + Builder::new("ibridge-tunnel") + .setup(|app, api| { + let handle = api.register_ios_plugin(init_plugin_ibridge_tunnel)?; + app.manage(IBridgeTunnel(handle)); + Ok(()) + }) + .build() +} diff --git a/src-tauri/src/account.rs b/src-tauri/src/account.rs index 8069eabc..22d2118f 100644 --- a/src-tauri/src/account.rs +++ b/src-tauri/src/account.rs @@ -20,6 +20,7 @@ use tracing::debug; use crate::{ error::AppError, + mobile_bootstrap::{MobileBootstrapAuth, MobileBootstrapAuthMutex}, secure_storage::create_sideloading_storage, sideload::{SideloaderGuard, SideloaderMutex}, }; @@ -29,14 +30,24 @@ pub async fn login_new( handle: AppHandle, window: Window, sideloader_state: State<'_, SideloaderMutex>, + mobile_auth_state: State<'_, MobileBootstrapAuthMutex>, email: String, password: String, anisette_server: String, save_credentials: bool, ) -> Result<(), AppError> { - let account = login(&handle, &window, &email, &password, anisette_server).await?; + let account = login(&handle, &window, &email, &password, anisette_server.clone()).await?; let mut sideloader_guard = sideloader_state.lock().unwrap(); *sideloader_guard = Some(account); + drop(sideloader_guard); + + let mut mobile_auth_guard = mobile_auth_state.lock().unwrap(); + *mobile_auth_guard = Some(MobileBootstrapAuth { + email: email.to_lowercase(), + password: password.clone(), + anisette_server, + }); + drop(mobile_auth_guard); if save_credentials { let pass_entry = Entry::new("iloader", &email).map_err(|e| { @@ -73,6 +84,7 @@ pub async fn login_stored( email: String, anisette_server: String, sideloader_state: State<'_, SideloaderMutex>, + mobile_auth_state: State<'_, MobileBootstrapAuthMutex>, ) -> Result<(), AppError> { let pass_entry = Entry::new("iloader", &email).map_err(|e| { AppError::KeyringWithMessage( @@ -83,9 +95,17 @@ pub async fn login_stored( let password = pass_entry.get_password().map_err(|e| { AppError::KeyringWithMessage("Failed to get credentials".to_string(), e.to_string()) })?; - let account = login(&handle, &window, &email, &password, anisette_server).await?; + let account = login(&handle, &window, &email, &password, anisette_server.clone()).await?; let mut sideloader_guard = sideloader_state.lock().unwrap(); *sideloader_guard = Some(account); + drop(sideloader_guard); + + let mut mobile_auth_guard = mobile_auth_state.lock().unwrap(); + *mobile_auth_guard = Some(MobileBootstrapAuth { + email: email.to_lowercase(), + password, + anisette_server, + }); Ok(()) } @@ -125,9 +145,16 @@ pub fn logged_in_as(sideloader_state: State<'_, SideloaderMutex>) -> Option) { +pub fn invalidate_account( + sideloader_state: State<'_, SideloaderMutex>, + mobile_auth_state: State<'_, MobileBootstrapAuthMutex>, +) { let mut sideloader_guard = sideloader_state.lock().unwrap(); *sideloader_guard = None; + drop(sideloader_guard); + + let mut mobile_auth_guard = mobile_auth_state.lock().unwrap(); + *mobile_auth_guard = None; } #[tauri::command] @@ -210,42 +237,42 @@ async fn login( debug!("Created developer session"); - let max_certs_callback = { - let window_clone = window.clone(); - move |certs: &Vec| -> Option> { - let cert_infos: Vec = certs - .iter() - .map(|cert| CertificateInfo { - name: cert.name.clone(), - certificate_id: cert.certificate_id.clone(), - serial_number: cert.serial_number.clone(), - machine_name: cert.machine_name.clone(), - machine_id: cert.machine_id.clone(), - }) - .collect(); - window_clone - .emit("max-certs-reached", cert_infos) - .expect("Failed to emit max-certs-reached event"); - - let (tx, rx) = std::sync::mpsc::channel::>>(); - let handler_id = window_clone.listen("max-certs-response", move |event| { - let certs = event.payload(); - let certs = serde_json::from_str::>>(certs).unwrap_or(None); - let _ = tx.send(certs); - }); - - let result = rx.recv_timeout(Duration::from_secs(300)); - window_clone.unlisten(handler_id); - result.unwrap_or(None) + let max_certs_window = window.clone(); + let max_certs_callback = Box::new(move |certs: &Vec| { + let window_clone = max_certs_window.clone(); + let cert_infos: Vec = certs + .iter() + .map(|cert| CertificateInfo { + name: cert.name.clone(), + certificate_id: cert.certificate_id.clone(), + serial_number: cert.serial_number.clone(), + machine_name: cert.machine_name.clone(), + machine_id: cert.machine_id.clone(), + }) + .collect(); + if let Err(error) = window_clone.emit("max-certs-reached", cert_infos) { + debug!("Failed to show certificate selection: {error}"); + return None; } - }; + + let (tx, rx) = std::sync::mpsc::channel::>>(); + let handler_id = window_clone.listen("max-certs-response", move |event| { + let certs = + serde_json::from_str::>>(event.payload()).unwrap_or(None); + let _ = tx.send(certs); + }); + + let result = rx.recv_timeout(Duration::from_secs(300)).ok().flatten(); + window_clone.unlisten(handler_id); + result + }); // TODO: Team Selection let sideloader = SideloaderBuilder::new(dev_session, email.to_lowercase()) .machine_name("iloader".into()) .storage(create_sideloading_storage(app)?) - .max_certs_behavior(MaxCertsBehavior::Prompt(Box::new(max_certs_callback))) + .max_certs_behavior(MaxCertsBehavior::Prompt(max_certs_callback)) .build(); debug!("Built sideloader"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b217fe90..a8d5921d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,7 +10,10 @@ mod pairing; mod secure_storage; mod error; mod logging; +pub mod mobile_bootstrap; +mod mobile_runtime; mod operation; +mod tunnel_control; use crate::{ account::{ @@ -20,24 +23,37 @@ use crate::{ device::{ DeviceInfoMutex, PairingCancelToken, cancel_pairing, list_devices, set_selected_device, }, + mobile_bootstrap::{MobileBootstrapAuthMutex, install_ibridge_mobile_operation}, + mobile_runtime::{ + mobile_refresh_self_operation, mobile_restore_bootstrap_account, mobile_runtime_status, + mobile_sideload_operation, + }, pairing::{ delete_stored_rppairing, export_pairing_cmd, has_stored_rppairing, installed_pairing_apps, place_pairing_cmd, }, secure_storage::{force_disable_keyring, keyring_available}, sideload::{SideloaderMutex, install_sidestore_operation, sideload_operation}, + tunnel_control::{mobile_tunnel_start, mobile_tunnel_status, mobile_tunnel_stop}, }; use tauri::Manager; use tracing_subscriber::{Layer, Registry, fmt, layer::SubscriberExt, util::SubscriberInitExt}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() + let builder = tauri::Builder::default() .plugin(tauri_plugin_process::init()) - .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_store::Builder::new().build()) + .plugin(tauri_plugin_store::Builder::new().build()); + + #[cfg(not(mobile))] + let builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); + + #[cfg(target_os = "ios")] + let builder = builder.plugin(tauri_plugin_ibridge_tunnel::init()); + + builder .setup(|app| { let log_dir = app .path() @@ -101,6 +117,7 @@ pub fn run() { app.manage(DeviceInfoMutex::new(None)); app.manage(SideloaderMutex::new(None)); app.manage(PairingCancelToken::new(None)); + app.manage(MobileBootstrapAuthMutex::new(None)); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -113,6 +130,14 @@ pub fn run() { sideload_operation, set_selected_device, install_sidestore_operation, + install_ibridge_mobile_operation, + mobile_restore_bootstrap_account, + mobile_runtime_status, + mobile_sideload_operation, + mobile_refresh_self_operation, + mobile_tunnel_start, + mobile_tunnel_status, + mobile_tunnel_stop, get_certificates, revoke_certificate, list_app_ids, diff --git a/src-tauri/src/mobile_bootstrap.rs b/src-tauri/src/mobile_bootstrap.rs new file mode 100644 index 00000000..97c58f56 --- /dev/null +++ b/src-tauri/src/mobile_bootstrap.rs @@ -0,0 +1,279 @@ +use std::sync::Mutex; + +use chrono::Utc; +use idevice::{IdeviceService, installation_proxy::InstallationProxyClient}; +use isideload::dev::app_ids::AppIdsApi; +use plist::Dictionary; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Manager, State, Window}; + +use crate::{ + device::{DeviceInfo, DeviceInfoMutex, get_provider}, + error::AppError, + operation::Operation, + pairing::place_file, + sideload::{SideloaderGuard, SideloaderMutex, download, sideload}, +}; + +pub const MOBILE_RELEASE_URL: &str = + "https://github.com/DannyBaanks/iBridge/releases/latest/download/iBridge-Mobile.ipa"; +const MOBILE_DISPLAY_NAME: &str = "iBridge"; +pub const MOBILE_BUNDLE_PREFIX: &str = "com.dannybaanks.ibridge.mobile"; +pub const MOBILE_PAIRING_PATH: &str = "iBridgeBootstrap/pairing.plist"; +pub const MOBILE_BOOTSTRAP_PATH: &str = "iBridgeBootstrap/bootstrap.json"; +pub const MOBILE_SECRET_PATH: &str = "iBridgeBootstrap/account-secret.json"; +const MOBILE_RELEASE_FILENAME: &str = "iBridge-Mobile.ipa"; +const NETWORK_EXTENSION_FEATURE: &str = "NWEXT04537"; + +#[derive(Clone)] +pub struct MobileBootstrapAuth { + pub email: String, + pub password: String, + pub anisette_server: String, +} + +pub type MobileBootstrapAuthMutex = Mutex>; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MobileBootstrapPayload { + pub(crate) schema: String, + pub(crate) device_udid: String, + pub(crate) device_name: String, + pub(crate) device_version: String, + pub(crate) apple_id: String, + pub(crate) anisette_server: String, + pub(crate) pairing_path: String, + pub(crate) secret_path: String, + pub(crate) created_at: String, +} + +impl MobileBootstrapPayload { + pub fn new( + device_udid: impl Into, + device_name: impl Into, + device_version: impl Into, + apple_id: impl Into, + anisette_server: impl Into, + ) -> Self { + Self { + schema: "ibridge.mobile-bootstrap/1".into(), + device_udid: device_udid.into(), + device_name: device_name.into(), + device_version: device_version.into(), + apple_id: apple_id.into(), + anisette_server: anisette_server.into(), + pairing_path: MOBILE_PAIRING_PATH.into(), + secret_path: MOBILE_SECRET_PATH.into(), + created_at: Utc::now().to_rfc3339(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MobileAccountSecret { + pub(crate) schema: String, + pub(crate) apple_id: String, + pub(crate) password: String, +} + +impl MobileAccountSecret { + pub fn new(apple_id: impl Into, password: impl Into) -> Self { + Self { + schema: "ibridge.mobile-account-secret/1".into(), + apple_id: apple_id.into(), + password: password.into(), + } + } +} + +pub fn is_ibridge_mobile_bundle_id(bundle_id: &str) -> bool { + bundle_id == MOBILE_BUNDLE_PREFIX + || bundle_id + .strip_prefix(MOBILE_BUNDLE_PREFIX) + .is_some_and(|suffix| suffix.starts_with('.')) +} + +async fn prepare_mobile_app_ids(sideloader_state: &SideloaderMutex) -> Result<(), AppError> { + let mut sideloader = SideloaderGuard::take(sideloader_state)?; + let team = sideloader.get_mut().get_team().await?; + let dev_session = sideloader.get_mut().get_dev_session(); + let existing = dev_session.list_app_ids(&team, None).await?.app_ids; + + let main_bundle_id = format!("{}.{}", MOBILE_BUNDLE_PREFIX, team.team_id); + let tunnel_bundle_id = format!("{}.tunnel", main_bundle_id); + + for (name, identifier) in [ + ("iBridge Mobile", main_bundle_id), + ("iBridge Tunnel", tunnel_bundle_id), + ] { + let mut app_id = if let Some(found) = existing + .iter() + .find(|app_id| app_id.identifier == identifier) + .cloned() + { + found + } else { + dev_session + .add_app_id(&team, name, &identifier, None) + .await? + }; + + let network_extension_enabled = app_id + .features + .get(NETWORK_EXTENSION_FEATURE) + .and_then(|value| value.as_boolean()) + .unwrap_or(false); + + if !network_extension_enabled { + let mut features = Dictionary::new(); + features.insert(NETWORK_EXTENSION_FEATURE.to_string(), true.into()); + app_id = dev_session + .update_app_id(&team, &app_id, features, None) + .await?; + + if !app_id + .features + .get(NETWORK_EXTENSION_FEATURE) + .and_then(|value| value.as_boolean()) + .unwrap_or(false) + { + return Err(AppError::Developer(format!( + "Apple did not enable Network Extensions for {identifier}. A developer team with the Network Extension capability is required." + ))); + } + } + } + + Ok(()) +} + +async fn installed_mobile_bundle_id(device: &DeviceInfo) -> Result { + let provider = get_provider(device).await?; + let mut installation_proxy = + InstallationProxyClient::connect(&provider) + .await + .map_err(|e| { + AppError::DeviceComsWithMessage( + "Failed to connect to installation proxy".into(), + e.to_string(), + ) + })?; + + let installed_apps = installation_proxy + .get_apps(Some("User"), None) + .await + .map_err(|e| { + AppError::DeviceComsWithMessage("Failed to get installed apps".into(), e.to_string()) + })?; + + for (bundle_id, app) in installed_apps { + let dictionary = match app.as_dictionary() { + Some(value) => value, + None => continue, + }; + let display_name = dictionary + .get("CFBundleDisplayName") + .or_else(|| dictionary.get("CFBundleName")) + .and_then(|value| value.as_string()); + + if is_ibridge_mobile_bundle_id(&bundle_id) || display_name == Some(MOBILE_DISPLAY_NAME) { + return Ok(bundle_id); + } + } + + Err(AppError::Misc( + "iBridge Mobile was installed but its bundle could not be found".into(), + )) +} + +#[tauri::command] +pub async fn install_ibridge_mobile_operation( + handle: AppHandle, + window: Window, + device_state: State<'_, DeviceInfoMutex>, + sideloader_state: State<'_, SideloaderMutex>, + mobile_auth_state: State<'_, MobileBootstrapAuthMutex>, +) -> Result<(), AppError> { + let op = Operation::new("install_ibridge_mobile".to_string(), &window); + + let device = { + let guard = device_state.lock().unwrap(); + guard.clone().ok_or(AppError::NoDeviceSelected)? + }; + let auth = { + let guard = mobile_auth_state.lock().unwrap(); + guard.clone().ok_or(AppError::NotLoggedIn)? + }; + + op.start("download")?; + let mobile_ipa_url = + std::env::var("IBRIDGE_MOBILE_IPA_URL").unwrap_or_else(|_| MOBILE_RELEASE_URL.to_string()); + let destination = handle + .path() + .temp_dir() + .map_err(|e| AppError::Filesystem("Failed to get temp dir".into(), e.to_string()))? + .join(MOBILE_RELEASE_FILENAME); + op.fail_if_err("download", download(&mobile_ipa_url, &destination).await)?; + + op.move_on("download", "install")?; + op.fail_if_err("install", prepare_mobile_app_ids(&sideloader_state).await)?; + op.fail_if_err( + "install", + sideload( + device_state, + sideloader_state, + destination.to_string_lossy().to_string(), + ) + .await, + )?; + + op.move_on("install", "bootstrap")?; + let bundle_id = op.fail_if_err("bootstrap", installed_mobile_bundle_id(&device.info).await)?; + let provider = op.fail_if_err("bootstrap", get_provider(&device.info).await)?; + + op.fail_if_err( + "bootstrap", + place_file( + device.pairing.clone(), + &provider, + bundle_id.clone(), + MOBILE_PAIRING_PATH.to_string(), + ) + .await, + )?; + + let payload = MobileBootstrapPayload::new( + device.info.udid, + device.info.name, + device.info.version, + auth.email.clone(), + auth.anisette_server, + ); + let payload = serde_json::to_vec(&payload) + .map_err(|e| AppError::Misc(format!("Failed to encode mobile bootstrap: {e}")))?; + + op.fail_if_err( + "bootstrap", + place_file( + payload, + &provider, + bundle_id.clone(), + MOBILE_BOOTSTRAP_PATH.to_string(), + ) + .await, + )?; + + let secret = MobileAccountSecret::new(auth.email, auth.password); + let secret = serde_json::to_vec(&secret) + .map_err(|e| AppError::Misc(format!("Failed to encode mobile account secret: {e}")))?; + + op.fail_if_err( + "bootstrap", + place_file(secret, &provider, bundle_id, MOBILE_SECRET_PATH.to_string()).await, + )?; + + op.complete("bootstrap")?; + Ok(()) +} diff --git a/src-tauri/src/mobile_runtime.rs b/src-tauri/src/mobile_runtime.rs new file mode 100644 index 00000000..d589396b --- /dev/null +++ b/src-tauri/src/mobile_runtime.rs @@ -0,0 +1,221 @@ +use std::{net::IpAddr, path::PathBuf, str::FromStr}; + +use idevice::{ + IdeviceService, lockdown::LockdownClient, pairing_file::PairingFile, provider::TcpProvider, +}; +use serde::Serialize; +use tauri::{AppHandle, Manager, State, Window}; + +use crate::{ + account::{login_new, login_stored}, + error::AppError, + mobile_bootstrap::{ + MOBILE_BOOTSTRAP_PATH, MOBILE_PAIRING_PATH, MOBILE_RELEASE_URL, MOBILE_SECRET_PATH, + MobileAccountSecret, MobileBootstrapAuthMutex, MobileBootstrapPayload, + }, + operation::Operation, + sideload::{SideloaderGuard, SideloaderMutex, download}, +}; + +pub const MOBILE_TUNNEL_HOST_IP: &str = "10.7.0.1"; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MobileRuntimeStatus { + pub pairing_ready: bool, + pub tunnel_reachable: bool, + pub tunnel_host: String, +} + +fn document_path(handle: &AppHandle, relative: &str) -> Result { + handle + .path() + .document_dir() + .map_err(|e| { + AppError::Filesystem( + "Failed to get app Documents directory".into(), + e.to_string(), + ) + }) + .map(|root| root.join(relative)) +} + +fn mobile_provider(handle: &AppHandle) -> Result { + let pairing_path = document_path(handle, MOBILE_PAIRING_PATH)?; + let pairing_bytes = std::fs::read(&pairing_path).map_err(|e| { + AppError::Filesystem( + "iBridge Mobile pairing bootstrap is missing".into(), + format!("{}: {e}", pairing_path.display()), + ) + })?; + let pairing_file = PairingFile::from_bytes(&pairing_bytes).map_err(|e| { + AppError::LockdownPairing( + "Failed to parse mobile pairing bootstrap".into(), + e.to_string(), + ) + })?; + let addr = IpAddr::from_str(MOBILE_TUNNEL_HOST_IP) + .map_err(|e| AppError::Misc(format!("Invalid mobile tunnel address: {e}")))?; + + Ok(TcpProvider { + addr, + scope_id: None, + pairing_file, + label: "iBridge Mobile".into(), + }) +} + +#[tauri::command] +pub async fn mobile_restore_bootstrap_account( + handle: AppHandle, + window: Window, + sideloader_state: State<'_, SideloaderMutex>, + mobile_auth_state: State<'_, MobileBootstrapAuthMutex>, +) -> Result { + let bootstrap_path = document_path(&handle, MOBILE_BOOTSTRAP_PATH)?; + let bootstrap_bytes = std::fs::read(&bootstrap_path).map_err(|e| { + AppError::Filesystem( + "iBridge Mobile bootstrap metadata is missing".into(), + format!("{}: {e}", bootstrap_path.display()), + ) + })?; + let bootstrap: MobileBootstrapPayload = serde_json::from_slice(&bootstrap_bytes) + .map_err(|e| AppError::Misc(format!("Invalid iBridge Mobile bootstrap metadata: {e}")))?; + + let secret_path = document_path(&handle, MOBILE_SECRET_PATH)?; + if secret_path.is_file() { + let secret_bytes = std::fs::read(&secret_path).map_err(|e| { + AppError::Filesystem( + "Failed to read iBridge Mobile account bootstrap".into(), + e.to_string(), + ) + })?; + let secret: MobileAccountSecret = serde_json::from_slice(&secret_bytes) + .map_err(|e| AppError::Misc(format!("Invalid mobile account bootstrap: {e}")))?; + if secret.apple_id != bootstrap.apple_id { + return Err(AppError::Misc( + "Mobile account bootstrap does not match bootstrap metadata".into(), + )); + } + + login_new( + handle.clone(), + window, + sideloader_state, + mobile_auth_state, + secret.apple_id.clone(), + secret.password, + bootstrap.anisette_server, + true, + ) + .await?; + + std::fs::remove_file(&secret_path).map_err(|e| { + AppError::Filesystem( + "Account imported but one-time bootstrap secret could not be deleted".into(), + e.to_string(), + ) + })?; + Ok(secret.apple_id) + } else { + login_stored( + handle, + window, + bootstrap.apple_id.clone(), + bootstrap.anisette_server, + sideloader_state, + mobile_auth_state, + ) + .await?; + Ok(bootstrap.apple_id) + } +} + +#[tauri::command] +pub async fn mobile_runtime_status(handle: AppHandle) -> Result { + let pairing_ready = document_path(&handle, MOBILE_PAIRING_PATH)?.is_file(); + if !pairing_ready { + return Ok(MobileRuntimeStatus { + pairing_ready: false, + tunnel_reachable: false, + tunnel_host: MOBILE_TUNNEL_HOST_IP.to_string(), + }); + } + + let provider = mobile_provider(&handle)?; + let tunnel_reachable = LockdownClient::connect(&provider).await.is_ok(); + + Ok(MobileRuntimeStatus { + pairing_ready, + tunnel_reachable, + tunnel_host: MOBILE_TUNNEL_HOST_IP.to_string(), + }) +} + +async fn install_with_mobile_provider( + handle: &AppHandle, + sideloader_state: State<'_, SideloaderMutex>, + app_path: String, +) -> Result<(), AppError> { + let provider = mobile_provider(handle)?; + let mut sideloader = SideloaderGuard::take(&sideloader_state)?; + sideloader + .get_mut() + .install_app( + &provider, + app_path.into(), + false, + None:: std::future::Ready<()>>, + ) + .await?; + Ok(()) +} + +#[tauri::command] +pub async fn mobile_sideload_operation( + handle: AppHandle, + window: Window, + sideloader_state: State<'_, SideloaderMutex>, + app_path: String, +) -> Result<(), AppError> { + let op = Operation::new("mobile_sideload".to_string(), &window); + op.start("install")?; + op.fail_if_err( + "install", + install_with_mobile_provider(&handle, sideloader_state, app_path).await, + )?; + op.complete("install")?; + Ok(()) +} + +#[tauri::command] +pub async fn mobile_refresh_self_operation( + handle: AppHandle, + window: Window, + sideloader_state: State<'_, SideloaderMutex>, +) -> Result<(), AppError> { + let op = Operation::new("mobile_refresh_self".to_string(), &window); + op.start("download")?; + + let source = + std::env::var("IBRIDGE_MOBILE_IPA_URL").unwrap_or_else(|_| MOBILE_RELEASE_URL.to_string()); + let destination = handle + .path() + .temp_dir() + .map_err(|e| AppError::Filesystem("Failed to get temp dir".into(), e.to_string()))? + .join("iBridge-Mobile-refresh.ipa"); + op.fail_if_err("download", download(&source, &destination).await)?; + + op.move_on("download", "install")?; + op.fail_if_err( + "install", + install_with_mobile_provider( + &handle, + sideloader_state, + destination.to_string_lossy().to_string(), + ) + .await, + )?; + op.complete("install")?; + Ok(()) +} diff --git a/src-tauri/src/tunnel_control.rs b/src-tauri/src/tunnel_control.rs new file mode 100644 index 00000000..536d77d7 --- /dev/null +++ b/src-tauri/src/tunnel_control.rs @@ -0,0 +1,83 @@ +use serde::Serialize; +use tauri::AppHandle; + +use crate::error::AppError; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TunnelControlStatus { + pub connected: bool, + pub status: String, +} + +#[tauri::command] +pub fn mobile_tunnel_start(handle: AppHandle) -> Result { + #[cfg(target_os = "ios")] + { + use tauri_plugin_ibridge_tunnel::IBridgeTunnelExt; + let status = handle + .ibridge_tunnel() + .start() + .map_err(|e| AppError::Misc(format!("Failed to start iBridge tunnel: {e}")))?; + return Ok(TunnelControlStatus { + connected: status.connected, + status: status.status, + }); + } + + #[cfg(not(target_os = "ios"))] + { + let _ = handle; + Err(AppError::Misc( + "The embedded iBridge tunnel is only available on iOS".into(), + )) + } +} + +#[tauri::command] +pub fn mobile_tunnel_status(handle: AppHandle) -> Result { + #[cfg(target_os = "ios")] + { + use tauri_plugin_ibridge_tunnel::IBridgeTunnelExt; + let status = handle + .ibridge_tunnel() + .status() + .map_err(|e| AppError::Misc(format!("Failed to query iBridge tunnel: {e}")))?; + return Ok(TunnelControlStatus { + connected: status.connected, + status: status.status, + }); + } + + #[cfg(not(target_os = "ios"))] + { + let _ = handle; + Err(AppError::Misc( + "The embedded iBridge tunnel is only available on iOS".into(), + )) + } +} + +#[tauri::command] +pub fn mobile_tunnel_stop(handle: AppHandle) -> Result { + #[cfg(target_os = "ios")] + { + use tauri_plugin_ibridge_tunnel::IBridgeTunnelExt; + let status = handle + .ibridge_tunnel() + .stop() + .map_err(|e| AppError::Misc(format!("Failed to stop iBridge tunnel: {e}")))?; + return Ok(TunnelControlStatus { + connected: status.connected, + status: status.status, + }); + } + + #[cfg(not(target_os = "ios"))] + { + let _ = handle; + Err(AppError::Misc( + "The embedded iBridge tunnel is only available on iOS".into(), + )) + } +} diff --git a/src-tauri/tauri.ios.conf.json b/src-tauri/tauri.ios.conf.json new file mode 100644 index 00000000..1c34227b --- /dev/null +++ b/src-tauri/tauri.ios.conf.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "iBridge", + "identifier": "com.dannybaanks.ibridge.mobile", + "bundle": { + "iOS": { + "minimumSystemVersion": "17.0", + "frameworks": ["NetworkExtension"], + "template": "src-tauri/ios/project.yml.hbs" + } + } +} diff --git a/src-tauri/tests/mobile_bootstrap_contract.rs b/src-tauri/tests/mobile_bootstrap_contract.rs new file mode 100644 index 00000000..4645ca6f --- /dev/null +++ b/src-tauri/tests/mobile_bootstrap_contract.rs @@ -0,0 +1,49 @@ +use iloader_lib::mobile_bootstrap::{ + MOBILE_BOOTSTRAP_PATH, MOBILE_PAIRING_PATH, MOBILE_RELEASE_URL, MOBILE_SECRET_PATH, + MobileBootstrapPayload, is_ibridge_mobile_bundle_id, +}; + +#[test] +fn mobile_release_contract_points_to_ibridge_asset() { + assert_eq!( + MOBILE_RELEASE_URL, + "https://github.com/DannyBaanks/iBridge/releases/latest/download/iBridge-Mobile.ipa" + ); + assert_eq!(MOBILE_PAIRING_PATH, "iBridgeBootstrap/pairing.plist"); + assert_eq!(MOBILE_BOOTSTRAP_PATH, "iBridgeBootstrap/bootstrap.json"); + assert_eq!(MOBILE_SECRET_PATH, "iBridgeBootstrap/account-secret.json"); +} + +#[test] +fn mobile_bundle_match_survives_team_suffix_added_by_isideload() { + assert!(is_ibridge_mobile_bundle_id( + "com.dannybaanks.ibridge.mobile" + )); + assert!(is_ibridge_mobile_bundle_id( + "com.dannybaanks.ibridge.mobile.A1B2C3D4E5" + )); + assert!(!is_ibridge_mobile_bundle_id("com.SideStore.SideStore")); +} + +#[test] +fn bootstrap_payload_contains_only_non_secret_account_metadata() { + let payload = MobileBootstrapPayload::new( + "00008110-001234567890001E", + "Danny's iPhone", + "26.4", + "danny@example.com", + "https://ani.example.test", + ); + + let json = serde_json::to_value(&payload).expect("bootstrap should serialize"); + + assert_eq!(json["schema"], "ibridge.mobile-bootstrap/1"); + assert_eq!(json["deviceUdid"], "00008110-001234567890001E"); + assert_eq!(json["deviceName"], "Danny's iPhone"); + assert_eq!(json["deviceVersion"], "26.4"); + assert_eq!(json["appleId"], "danny@example.com"); + assert_eq!(json["anisetteServer"], "https://ani.example.test"); + assert_eq!(json["pairingPath"], MOBILE_PAIRING_PATH); + assert_eq!(json["secretPath"], MOBILE_SECRET_PATH); + assert!(json.get("password").is_none()); +} diff --git a/src-tauri/tests/mobile_secret_contract.rs b/src-tauri/tests/mobile_secret_contract.rs new file mode 100644 index 00000000..414b9b97 --- /dev/null +++ b/src-tauri/tests/mobile_secret_contract.rs @@ -0,0 +1,26 @@ +use iloader_lib::mobile_bootstrap::{MobileAccountSecret, MobileBootstrapPayload}; + +#[test] +fn account_secret_is_explicitly_versioned_and_contains_login_material() { + let secret = MobileAccountSecret::new("user@example.test", "fixture-secret-value"); + let json = serde_json::to_value(&secret).expect("secret should serialize"); + + assert_eq!(json["schema"], "ibridge.mobile-account-secret/1"); + assert_eq!(json["appleId"], "user@example.test"); + assert_eq!(json["password"], "fixture-secret-value"); +} + +#[test] +fn public_bootstrap_never_serializes_login_secret() { + let payload = MobileBootstrapPayload::new( + "TEST-UDID", + "Test iPhone", + "26.4", + "user@example.test", + "https://anisette.example.test", + ); + let encoded = serde_json::to_string(&payload).expect("bootstrap should serialize"); + + assert!(!encoded.contains("fixture-secret-value")); + assert!(!encoded.contains("password")); +} diff --git a/src/App.tsx b/src/App.tsx index 85bcd910..1adcab02 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { open as openFileDialog } from "@tauri-apps/plugin-dialog"; import { openUrl } from "@tauri-apps/plugin-opener"; import { sideloadOperation, + installIBridgeMobileOperation, installSideStoreOperation, installLiveContainerOperation, Operation, @@ -185,7 +186,7 @@ function App() {
{t("app.logo_alt")}
-

iloader

+

iBridge

{t("subtitle")}

@@ -198,7 +199,7 @@ function App() { className="toolbar-button" onClick={async () => { try { - await openUrl("https://github.com/nab138/iloader"); + await openUrl("https://github.com/DannyBaanks/iBridge"); } catch (error) { console.error("Failed to open GitHub link", error); toast.error(t("app.open_github_failed")); @@ -214,7 +215,6 @@ function App() {

{t("app.section_account")}

- {/* here to ensure spacing and stuff is correct */} @@ -305,6 +305,17 @@ function App() {
+ +
+ )} +
+
+ +
+

Apple ID

+ + {restoring &&

Restoring desktop setup…

} + +
+
+ +
+

Apps

+ +
+ + + +
+
+
+ + {operationState && ( + setOperationState(null)} + /> + )} + + + + ); +} diff --git a/src/PlatformContext.tsx b/src/PlatformContext.tsx index 41385914..91649470 100644 --- a/src/PlatformContext.tsx +++ b/src/PlatformContext.tsx @@ -1,21 +1,26 @@ import React, { createContext, useContext, useEffect, useState } from "react"; import "./DialogContext.css"; +export type IBridgePlatform = "windows" | "mac" | "linux" | "ios"; + export const PlatformContext = createContext<{ - platform: "windows" | "mac" | "linux"; + platform: IBridgePlatform; }>({ platform: "windows" }); export const PlatformProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { - const [platform, setPlatform] = useState<"mac" | "windows" | "linux">( - "windows", - ); + const [platform, setPlatform] = useState("windows"); useEffect(() => { if (typeof navigator === "undefined") return; const ua = navigator.userAgent || ""; - if (ua.includes("Mac")) { + const platformName = navigator.platform || ""; + const touchMac = platformName === "MacIntel" && navigator.maxTouchPoints > 1; + + if (/iPhone|iPad|iPod/i.test(ua) || touchMac) { + setPlatform("ios"); + } else if (ua.includes("Mac")) { setPlatform("mac"); } else if (ua.includes("Win")) { setPlatform("windows"); @@ -25,11 +30,7 @@ export const PlatformProvider: React.FC<{ children: React.ReactNode }> = ({ }, []); return ( - + {children} ); diff --git a/src/components/operations.ts b/src/components/operations.ts index 3b5fdb46..0db4c447 100644 --- a/src/components/operations.ts +++ b/src/components/operations.ts @@ -36,6 +36,27 @@ type OperationFailedUpdate = { export type OperationUpdate = OperationInfoUpdate | OperationFailedUpdate; +export const installIBridgeMobileOperation: Operation = { + id: "install_ibridge_mobile", + titleKey: "operations.install_ibridge_mobile_title", + successTitleKey: "operations.install_ibridge_mobile_success_title", + successMessageKey: "operations.install_ibridge_mobile_success_message", + steps: [ + { + id: "download", + titleKey: "operations.install_ibridge_mobile_step_download", + }, + { + id: "install", + titleKey: "operations.install_ibridge_mobile_step_install", + }, + { + id: "bootstrap", + titleKey: "operations.install_ibridge_mobile_step_bootstrap", + }, + ], +}; + export const installSideStoreOperation: Operation = { id: "install_sidestore", titleKey: "operations.install_sidestore_title", diff --git a/src/errors.tsx b/src/errors.tsx index b4baa74b..44b85363 100644 --- a/src/errors.tsx +++ b/src/errors.tsx @@ -45,6 +45,7 @@ export const errorSuggestionKeys = { } as const; export type ErrorVariant = keyof typeof errorSuggestionKeys; +export type ErrorSuggestionPlatform = "mac" | "windows" | "linux" | "ios"; export type AppError = { type: ErrorVariant; @@ -69,7 +70,7 @@ const dedupeSuggestions = (suggestions: string[]) => { const getSuggestionBlock = ( t: TFunction, key: string, - platform: "mac" | "windows" | "linux", + platform: ErrorSuggestionPlatform, anisetteServer: string, ) => { const rawSuggestions = t(key, { @@ -117,7 +118,7 @@ const getSuggestionBlock = ( export const getErrorSuggestions = ( t: TFunction, type: ErrorVariant, - platform: "mac" | "windows" | "linux", + platform: ErrorSuggestionPlatform, anisetteServer: string, ): string[] => { return dedupeSuggestions( diff --git a/src/i18next.ts b/src/i18next.ts index 2953d667..2bf2df64 100644 --- a/src/i18next.ts +++ b/src/i18next.ts @@ -29,7 +29,7 @@ const languages = [ ["hu", "Magyar"], ["kh", "ភាសាខ្មែរ"], ["id", "Bahasa Indonesia"], - ["pt_br", "Português (Brasileiro)"] + ["pt_br", "Português (Brasileiro)"], ] as const; export const sortedLanguages = [...languages].sort((a, b) => @@ -37,6 +37,7 @@ export const sortedLanguages = [...languages].sort((a, b) => ); type TranslationResource = Record; +type ResourceMap = Record; const localeModules = import.meta.glob<{ default: TranslationResource }>( "./locales/*.json", @@ -52,7 +53,57 @@ const resources = Object.fromEntries( return [[lang, { translation: module.default }]]; }), -); +) as ResourceMap; + +const iBridgeCopy = { + en: { + app: { ibridge_mobile: "Install iBridge Mobile" }, + operations: { + install_ibridge_mobile_title: "Installing iBridge Mobile", + install_ibridge_mobile_success_title: "iBridge Mobile Installed!", + install_ibridge_mobile_success_message: + "Open iBridge on your iPhone. Pairing and account bootstrap are already in place.", + install_ibridge_mobile_step_download: "Download iBridge Mobile", + install_ibridge_mobile_step_install: "Sign & Install iBridge Mobile", + install_ibridge_mobile_step_bootstrap: "Configure This iPhone", + mobile_sideload_title: "Installing IPA", + mobile_sideload_step_install: "Sign & Install IPA", + mobile_refresh_title: "Refreshing iBridge", + mobile_refresh_success_title: "iBridge Refreshed!", + mobile_refresh_step_download: "Download iBridge Mobile", + mobile_refresh_step_install: "Sign & Refresh iBridge", + }, + }, + es: { + app: { ibridge_mobile: "Instalar iBridge Mobile" }, + operations: { + install_ibridge_mobile_title: "Instalando iBridge Mobile", + install_ibridge_mobile_success_title: "¡iBridge Mobile instalado!", + install_ibridge_mobile_success_message: + "Abre iBridge en tu iPhone. El pairing y la cuenta ya quedaron configurados.", + install_ibridge_mobile_step_download: "Descargar iBridge Mobile", + install_ibridge_mobile_step_install: "Firmar e instalar iBridge Mobile", + install_ibridge_mobile_step_bootstrap: "Configurar este iPhone", + mobile_sideload_title: "Instalando IPA", + mobile_sideload_step_install: "Firmar e instalar IPA", + mobile_refresh_title: "Refrescando iBridge", + mobile_refresh_success_title: "¡iBridge refrescado!", + mobile_refresh_step_download: "Descargar iBridge Mobile", + mobile_refresh_step_install: "Firmar y refrescar iBridge", + }, + }, +} satisfies Record>>; + +for (const [language, sections] of Object.entries(iBridgeCopy)) { + const translation = resources[language]?.translation; + if (!translation) continue; + for (const [section, copy] of Object.entries(sections)) { + translation[section] = { + ...((translation[section] as Record | undefined) ?? {}), + ...copy, + }; + } +} i18n .use(LanguageDetector) diff --git a/src/main.tsx b/src/main.tsx index ec8c10cf..b26e9a9d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; +import MobileApp from "./MobileApp"; import { Toaster } from "sonner"; import { StoreProvider } from "./StoreContext"; import { LogProvider } from "./LogContext"; @@ -9,6 +10,12 @@ import { DialogProvider } from "./DialogContext"; import "./i18next"; import { PlatformProvider } from "./PlatformContext"; +const ua = navigator.userAgent || ""; +const platformName = navigator.platform || ""; +const isTouchMac = platformName === "MacIntel" && navigator.maxTouchPoints > 1; +const isIOS = /iPhone|iPad|iPod/i.test(ua) || isTouchMac; +const RootApp = isIOS ? MobileApp : App; + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( @@ -16,7 +23,7 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - +