init
This commit is contained in:
39
frontend/src/anisette-libcurl-http.ts
Normal file
39
frontend/src/anisette-libcurl-http.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { HttpClient } from "@lbr77/anisette-js"
|
||||
import { initLibcurl, libcurl } from "./anisette-libcurl-init"
|
||||
|
||||
export class LibcurlHttpClient implements HttpClient {
|
||||
async get(url: string, headers: Record<string, string>): Promise<Uint8Array> {
|
||||
await initLibcurl()
|
||||
|
||||
const response = await libcurl.fetch(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
insecure: true,
|
||||
_libcurl_http_version: 1.1,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP GET ${url} failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
return new Uint8Array(await response.arrayBuffer())
|
||||
}
|
||||
|
||||
async post(url: string, body: string, headers: Record<string, string>): Promise<Uint8Array> {
|
||||
await initLibcurl()
|
||||
|
||||
const response = await libcurl.fetch(url, {
|
||||
method: "POST",
|
||||
body,
|
||||
headers,
|
||||
insecure: true,
|
||||
_libcurl_http_version: 1.1,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP POST ${url} failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
return new Uint8Array(await response.arrayBuffer())
|
||||
}
|
||||
}
|
||||
26
frontend/src/anisette-libcurl-init.ts
Normal file
26
frontend/src/anisette-libcurl-init.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// @ts-ignore
|
||||
import { libcurl } from "../public/anisette/libcurl_full.mjs"
|
||||
|
||||
let initialized = false
|
||||
let initPromise: Promise<void> | null = null
|
||||
|
||||
export async function initLibcurl(): Promise<void> {
|
||||
if (initialized) {
|
||||
return
|
||||
}
|
||||
if (initPromise) {
|
||||
return initPromise
|
||||
}
|
||||
|
||||
initPromise = (async () => {
|
||||
const wsProto = location.protocol === "https:" ? "wss:" : "ws:"
|
||||
const wsUrl = `${wsProto}//${location.host}/wisp/`
|
||||
libcurl.set_websocket(wsUrl)
|
||||
await libcurl.load_wasm()
|
||||
initialized = true
|
||||
})()
|
||||
|
||||
return initPromise
|
||||
}
|
||||
|
||||
export { libcurl }
|
||||
76
frontend/src/anisette-service.ts
Normal file
76
frontend/src/anisette-service.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Anisette, loadWasmModule } from "@lbr77/anisette-js"
|
||||
import { initLibcurl } from "./anisette-libcurl-init"
|
||||
import { LibcurlHttpClient } from "./anisette-libcurl-http"
|
||||
|
||||
export interface AnisetteData {
|
||||
machineID: string
|
||||
oneTimePassword: string
|
||||
localUserID: string
|
||||
routingInfo: number
|
||||
deviceUniqueIdentifier: string
|
||||
deviceDescription: string
|
||||
deviceSerialNumber: string
|
||||
date: Date
|
||||
locale: string
|
||||
timeZone: string
|
||||
}
|
||||
|
||||
let anisetteInstance: Anisette | null = null
|
||||
|
||||
export async function initAnisette(): Promise<Anisette> {
|
||||
if (anisetteInstance) {
|
||||
return anisetteInstance
|
||||
}
|
||||
|
||||
await initLibcurl()
|
||||
const httpClient = new LibcurlHttpClient()
|
||||
|
||||
const wasmModule = await loadWasmModule()
|
||||
const [storeservicescore, coreadi] = await Promise.all([
|
||||
fetch("/anisette/libstoreservicescore.so").then((response) => response.arrayBuffer()).then((arr) => new Uint8Array(arr)),
|
||||
fetch("/anisette/libCoreADI.so").then((response) => response.arrayBuffer()).then((arr) => new Uint8Array(arr)),
|
||||
])
|
||||
|
||||
anisetteInstance = await Anisette.fromSo(storeservicescore, coreadi, wasmModule, {
|
||||
httpClient,
|
||||
init: {
|
||||
libraryPath: "./anisette/",
|
||||
},
|
||||
})
|
||||
|
||||
return anisetteInstance
|
||||
}
|
||||
|
||||
export async function provisionAnisette(): Promise<void> {
|
||||
const anisette = await initAnisette()
|
||||
if (!anisette.isProvisioned) {
|
||||
await anisette.provision()
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAnisetteData(): Promise<AnisetteData> {
|
||||
const anisette = await initAnisette()
|
||||
|
||||
if (!anisette.isProvisioned) {
|
||||
await anisette.provision()
|
||||
}
|
||||
|
||||
const headers = await anisette.getData()
|
||||
|
||||
return {
|
||||
machineID: headers["X-Apple-I-MD-M"],
|
||||
oneTimePassword: headers["X-Apple-I-MD"],
|
||||
localUserID: headers["X-Apple-I-MD-LU"],
|
||||
routingInfo: Number.parseInt(headers["X-Apple-I-MD-RINFO"], 10),
|
||||
deviceUniqueIdentifier: headers["X-Mme-Device-Id"],
|
||||
deviceDescription: headers["X-MMe-Client-Info"],
|
||||
deviceSerialNumber: headers["X-Apple-I-SRL-NO"] || "0",
|
||||
date: new Date(headers["X-Apple-I-Client-Time"]),
|
||||
locale: headers["X-Apple-Locale"],
|
||||
timeZone: headers["X-Apple-I-TimeZone"],
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAnisetteCache(): void {
|
||||
anisetteInstance = null
|
||||
}
|
||||
573
frontend/src/apple-signing.ts
Normal file
573
frontend/src/apple-signing.ts
Normal file
@@ -0,0 +1,573 @@
|
||||
import { strFromU8, unzipSync } from "fflate"
|
||||
import {
|
||||
AppleAPI,
|
||||
Fetch,
|
||||
signIPA,
|
||||
type AnisetteData,
|
||||
type AppID,
|
||||
type Certificate,
|
||||
type Device,
|
||||
type Team,
|
||||
} from "altsign.js"
|
||||
import { initLibcurl, libcurl } from "./anisette-libcurl-init"
|
||||
|
||||
const SIGNING_IDENTITY_STORAGE_KEY = "webmuxd:signing-identities"
|
||||
const PRIMARY_APP_INFO_PLIST_RE = /^Payload\/[^/]+\.app\/Info\.plist$/
|
||||
|
||||
interface ParsedIpaInfo {
|
||||
bundleId?: string
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
interface CachedSigningIdentityPayload {
|
||||
certId: string
|
||||
certPublicKeyBase64: string
|
||||
privateKeyBase64: string
|
||||
}
|
||||
|
||||
interface StoredSigningIdentityMap {
|
||||
[appleAndTeamKey: string]: CachedSigningIdentityPayload
|
||||
}
|
||||
|
||||
export interface AppleSigningCredentials {
|
||||
appleId: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface AppleSigningRequest {
|
||||
ipaFile: File
|
||||
anisetteData: AnisetteData
|
||||
credentials: AppleSigningCredentials
|
||||
deviceUdid: string
|
||||
deviceName?: string
|
||||
bundleIdOverride?: string
|
||||
displayNameOverride?: string
|
||||
onLog: (message: string) => void
|
||||
}
|
||||
|
||||
export interface AppleSigningResult {
|
||||
signedFile: File
|
||||
outputBundleId: string
|
||||
teamId: string
|
||||
}
|
||||
|
||||
let appleApiInstance: AppleAPI | null = null
|
||||
|
||||
function getAppleApi(): AppleAPI {
|
||||
if (appleApiInstance) {
|
||||
return appleApiInstance
|
||||
}
|
||||
const appleFetch = new Fetch(initLibcurl, async (url, options) => {
|
||||
const response = await libcurl.fetch(url, {
|
||||
method: options.method,
|
||||
headers: options.headers,
|
||||
body: options.body,
|
||||
redirect: "manual",
|
||||
insecure: true,
|
||||
_libcurl_http_version: 1.1,
|
||||
} as never)
|
||||
return response
|
||||
})
|
||||
appleApiInstance = new AppleAPI(appleFetch)
|
||||
return appleApiInstance
|
||||
}
|
||||
|
||||
export async function signIpaWithApple(
|
||||
request: AppleSigningRequest,
|
||||
): Promise<AppleSigningResult> {
|
||||
const { ipaFile, anisetteData, credentials, onLog } = request
|
||||
const ipaData = new Uint8Array(await ipaFile.arrayBuffer())
|
||||
const ipaInfo = readIpaInfo(ipaData)
|
||||
|
||||
const bundleIdBase = (request.bundleIdOverride ?? ipaInfo.bundleId ?? "").trim()
|
||||
if (bundleIdBase.length === 0) {
|
||||
throw new Error("Cannot sign IPA: bundle identifier is missing")
|
||||
}
|
||||
|
||||
const appleId = credentials.appleId.trim()
|
||||
const password = credentials.password
|
||||
if (!appleId || !password) {
|
||||
throw new Error("Cannot sign IPA: Apple ID or password is empty")
|
||||
}
|
||||
|
||||
onLog(`Signing stage: authenticating Apple account ${maskEmail(appleId)}...`)
|
||||
const api = getAppleApi()
|
||||
const { session } = await api.authenticate(appleId, password, anisetteData, (submitCode) => {
|
||||
const code = window.prompt("Apple 2FA code")
|
||||
if (!code || code.trim().length === 0) {
|
||||
throw new Error("2FA code is required")
|
||||
}
|
||||
submitCode(code.trim())
|
||||
})
|
||||
|
||||
const team = await api.fetchTeam(session)
|
||||
onLog(`Signing stage: using team ${team.identifier} (${team.name}).`)
|
||||
|
||||
const finalBundleId = buildTeamScopedBundleId(bundleIdBase, team.identifier)
|
||||
const displayName = (request.displayNameOverride ?? ipaInfo.displayName ?? "").trim()
|
||||
|
||||
const identity = await ensureSigningIdentity(api, session, team, appleId, onLog)
|
||||
await ensureDeviceRegistered(
|
||||
api,
|
||||
session,
|
||||
team,
|
||||
request.deviceUdid,
|
||||
request.deviceName,
|
||||
onLog,
|
||||
)
|
||||
const appId = await ensureAppId(api, session, team, finalBundleId, onLog)
|
||||
|
||||
onLog("Signing stage: fetching provisioning profile...")
|
||||
const provisioningProfile = await api.fetchProvisioningProfile(session, team, appId)
|
||||
|
||||
onLog("Signing stage: resigning IPA in browser...")
|
||||
const signed = await signIPA({
|
||||
ipaData,
|
||||
certificate: identity.certificate.publicKey,
|
||||
privateKey: identity.privateKey,
|
||||
provisioningProfile: provisioningProfile.data,
|
||||
bundleID: finalBundleId,
|
||||
displayName: displayName.length > 0 ? displayName : undefined,
|
||||
adhoc: false,
|
||||
forceSign: true,
|
||||
})
|
||||
|
||||
const outputFileName = toSignedFileName(ipaFile.name)
|
||||
const signedArray = new Uint8Array(signed.data.byteLength)
|
||||
signedArray.set(signed.data)
|
||||
const signedBuffer = signedArray.buffer.slice(0)
|
||||
const signedFile = new File([signedBuffer], outputFileName, {
|
||||
type: "application/octet-stream",
|
||||
})
|
||||
onLog(`Signing stage: complete (${signed.data.byteLength} bytes).`)
|
||||
|
||||
return {
|
||||
signedFile,
|
||||
outputBundleId: finalBundleId,
|
||||
teamId: team.identifier,
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSigningIdentity(
|
||||
api: AppleAPI,
|
||||
session: { anisetteData: AnisetteData; dsid: string; authToken: string },
|
||||
team: Team,
|
||||
appleId: string,
|
||||
onLog: (message: string) => void,
|
||||
): Promise<{ certificate: Certificate; privateKey: Uint8Array }> {
|
||||
const certificates = await api.fetchCertificates(session, team)
|
||||
const cached = loadCachedSigningIdentity(appleId, team.identifier)
|
||||
|
||||
if (cached) {
|
||||
const matched = certificates.find((item) => item.identifier === cached.certId)
|
||||
if (matched) {
|
||||
onLog(`Signing stage: using cached certificate ${matched.identifier}.`)
|
||||
return {
|
||||
certificate: {
|
||||
...matched,
|
||||
publicKey: base64ToBytes(cached.certPublicKeyBase64),
|
||||
},
|
||||
privateKey: base64ToBytes(cached.privateKeyBase64),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onLog("Signing stage: creating development certificate...")
|
||||
let created: { certificate: Certificate; privateKey: Uint8Array }
|
||||
try {
|
||||
created = await api.addCertificate(session, team, `webmuxd-${Date.now()}`)
|
||||
} catch (error) {
|
||||
const message = String(error)
|
||||
if (!message.includes("7460") || certificates.length === 0) {
|
||||
throw error
|
||||
}
|
||||
const target = certificates[0]
|
||||
onLog(`Signing stage: certificate limit hit, revoking ${target.identifier}...`)
|
||||
await api.revokeCertificate(session, team, target)
|
||||
created = await api.addCertificate(session, team, `webmuxd-${Date.now()}`)
|
||||
}
|
||||
|
||||
saveCachedSigningIdentity(appleId, team.identifier, {
|
||||
certId: created.certificate.identifier,
|
||||
certPublicKeyBase64: bytesToBase64(created.certificate.publicKey),
|
||||
privateKeyBase64: bytesToBase64(created.privateKey),
|
||||
})
|
||||
onLog(`Signing stage: certificate ready ${created.certificate.identifier}.`)
|
||||
return created
|
||||
}
|
||||
|
||||
async function ensureDeviceRegistered(
|
||||
api: AppleAPI,
|
||||
session: { anisetteData: AnisetteData; dsid: string; authToken: string },
|
||||
team: Team,
|
||||
deviceUdid: string,
|
||||
deviceName: string | undefined,
|
||||
onLog: (message: string) => void,
|
||||
): Promise<void> {
|
||||
const normalizedUdid = normalizeUdid(deviceUdid)
|
||||
if (!normalizedUdid) {
|
||||
onLog("Signing stage: skip device registration because UDID is empty.")
|
||||
return
|
||||
}
|
||||
|
||||
let devices: Device[] = []
|
||||
try {
|
||||
devices = await api.fetchDevices(session, team)
|
||||
} catch (error) {
|
||||
onLog(`Signing stage: fetchDevices failed, skip registration check: ${formatError(error)}`)
|
||||
}
|
||||
const existed = findRegisteredDevice(devices, normalizedUdid)
|
||||
if (existed) {
|
||||
onLog(`Signing stage: device already registered (${existed.identifier}).`)
|
||||
return
|
||||
}
|
||||
|
||||
const registerName =
|
||||
deviceName && deviceName.trim().length > 0
|
||||
? deviceName.trim()
|
||||
: `webmuxd-${normalizedUdid.slice(-6)}`
|
||||
try {
|
||||
onLog(`Signing stage: registering device ${normalizedUdid} as ${registerName}...`)
|
||||
await api.registerDevice(session, team, registerName, normalizedUdid)
|
||||
onLog(`Signing stage: device registered (${normalizedUdid}).`)
|
||||
} catch (error) {
|
||||
onLog(`Signing stage: register failed, skip and continue: ${formatError(error)}`)
|
||||
try {
|
||||
const latestDevices = await api.fetchDevices(session, team)
|
||||
const registered = findRegisteredDevice(latestDevices, normalizedUdid)
|
||||
if (registered) {
|
||||
onLog(`Signing stage: device confirmed in developer list (${registered.identifier}).`)
|
||||
return
|
||||
}
|
||||
} catch (verifyError) {
|
||||
onLog(`Signing stage: device verify after failure also failed: ${formatError(verifyError)}`)
|
||||
}
|
||||
onLog("Signing stage: continue without registration (may affect profile generation).")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const latestDevices = await api.fetchDevices(session, team)
|
||||
const registered = findRegisteredDevice(latestDevices, normalizedUdid)
|
||||
if (registered) {
|
||||
onLog(`Signing stage: device confirmed in developer list (${registered.identifier}).`)
|
||||
}
|
||||
} catch (error) {
|
||||
onLog(`Signing stage: device verification skipped: ${formatError(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureAppId(
|
||||
api: AppleAPI,
|
||||
session: { anisetteData: AnisetteData; dsid: string; authToken: string },
|
||||
team: Team,
|
||||
bundleId: string,
|
||||
onLog: (message: string) => void,
|
||||
): Promise<AppID> {
|
||||
const appIds = await api.fetchAppIDs(session, team)
|
||||
const matched = appIds.find((item) => item.bundleIdentifier === bundleId)
|
||||
if (matched) {
|
||||
onLog(`Signing stage: reuse App ID ${bundleId}.`)
|
||||
return matched
|
||||
}
|
||||
onLog(`Signing stage: creating App ID ${bundleId}...`)
|
||||
return api.addAppID(session, team, "WebMuxD Signed App", bundleId)
|
||||
}
|
||||
|
||||
function readIpaInfo(ipaBytes: Uint8Array): ParsedIpaInfo {
|
||||
const files = unzipSync(ipaBytes, {
|
||||
filter: (file) => PRIMARY_APP_INFO_PLIST_RE.test(file.name),
|
||||
})
|
||||
const infoName = Object.keys(files).find((name) => PRIMARY_APP_INFO_PLIST_RE.test(name))
|
||||
if (!infoName) {
|
||||
return {}
|
||||
}
|
||||
const plistData = parseInfoPlist(files[infoName])
|
||||
if (!plistData || typeof plistData !== "object" || Array.isArray(plistData)) {
|
||||
return {}
|
||||
}
|
||||
const data = plistData as Record<string, unknown>
|
||||
const bundleId =
|
||||
typeof data.CFBundleIdentifier === "string" ? (data.CFBundleIdentifier as string) : undefined
|
||||
const displayName =
|
||||
typeof data.CFBundleDisplayName === "string"
|
||||
? (data.CFBundleDisplayName as string)
|
||||
: typeof data.CFBundleName === "string"
|
||||
? (data.CFBundleName as string)
|
||||
: undefined
|
||||
return { bundleId, displayName }
|
||||
}
|
||||
|
||||
function parseInfoPlist(infoPlistBytes: Uint8Array): unknown {
|
||||
if (strFromU8(infoPlistBytes.subarray(0, 8)) === "bplist00") {
|
||||
return parseBinaryPlist(infoPlistBytes)
|
||||
}
|
||||
const xml = strFromU8(infoPlistBytes)
|
||||
return parseXmlPlist(xml)
|
||||
}
|
||||
|
||||
function parseXmlPlist(xml: string): unknown {
|
||||
const doc = new DOMParser().parseFromString(xml, "application/xml")
|
||||
const parserError = doc.querySelector("parsererror")
|
||||
if (parserError) {
|
||||
return {}
|
||||
}
|
||||
const root = doc.querySelector("plist > *")
|
||||
if (!root) {
|
||||
return {}
|
||||
}
|
||||
return parseXmlNode(root)
|
||||
}
|
||||
|
||||
function parseXmlNode(node: Element): unknown {
|
||||
switch (node.tagName) {
|
||||
case "dict": {
|
||||
const map: Record<string, unknown> = {}
|
||||
const children = Array.from(node.children)
|
||||
for (let i = 0; i < children.length - 1; i += 2) {
|
||||
const keyNode = children[i]
|
||||
const valueNode = children[i + 1]
|
||||
if (keyNode.tagName !== "key") {
|
||||
continue
|
||||
}
|
||||
map[keyNode.textContent ?? ""] = parseXmlNode(valueNode)
|
||||
}
|
||||
return map
|
||||
}
|
||||
case "array":
|
||||
return Array.from(node.children).map((child) => parseXmlNode(child))
|
||||
case "string":
|
||||
case "date":
|
||||
return node.textContent ?? ""
|
||||
case "integer":
|
||||
return Number.parseInt(node.textContent ?? "0", 10)
|
||||
case "real":
|
||||
return Number.parseFloat(node.textContent ?? "0")
|
||||
case "true":
|
||||
return true
|
||||
case "false":
|
||||
return false
|
||||
case "data":
|
||||
return base64ToBytes((node.textContent ?? "").trim())
|
||||
default:
|
||||
return node.textContent ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
function parseBinaryPlist(bytes: Uint8Array): unknown {
|
||||
if (bytes.length < 40 || strFromU8(bytes.subarray(0, 8)) !== "bplist00") {
|
||||
throw new Error("Invalid binary plist")
|
||||
}
|
||||
|
||||
const trailerOffset = bytes.length - 32
|
||||
const offsetSize = bytes[trailerOffset + 6]
|
||||
const objectRefSize = bytes[trailerOffset + 7]
|
||||
const objectCount = readUInt(bytes, trailerOffset + 8, 8)
|
||||
const topObject = readUInt(bytes, trailerOffset + 16, 8)
|
||||
const offsetTableStart = readUInt(bytes, trailerOffset + 24, 8)
|
||||
|
||||
const objectOffsets = new Array<number>(objectCount)
|
||||
for (let i = 0; i < objectCount; i += 1) {
|
||||
const entryOffset = offsetTableStart + i * offsetSize
|
||||
objectOffsets[i] = readUInt(bytes, entryOffset, offsetSize)
|
||||
}
|
||||
|
||||
const memo = new Map<number, unknown>()
|
||||
|
||||
const readLength = (offset: number, objectInfo: number): { length: number; nextOffset: number } => {
|
||||
if (objectInfo < 0x0f) {
|
||||
return { length: objectInfo, nextOffset: offset + 1 }
|
||||
}
|
||||
const marker = bytes[offset + 1]
|
||||
const markerType = marker >> 4
|
||||
const markerInfo = marker & 0x0f
|
||||
if (markerType !== 0x1) {
|
||||
throw new Error("Invalid binary plist length marker")
|
||||
}
|
||||
const intSize = 1 << markerInfo
|
||||
const intOffset = offset + 2
|
||||
return {
|
||||
length: readUInt(bytes, intOffset, intSize),
|
||||
nextOffset: intOffset + intSize,
|
||||
}
|
||||
}
|
||||
|
||||
const parseObject = (index: number): unknown => {
|
||||
if (memo.has(index)) {
|
||||
return memo.get(index)
|
||||
}
|
||||
const offset = objectOffsets[index]
|
||||
const marker = bytes[offset]
|
||||
const objectType = marker >> 4
|
||||
const objectInfo = marker & 0x0f
|
||||
|
||||
let value: unknown
|
||||
if (objectType === 0x0) {
|
||||
value = objectInfo === 0x8 ? false : objectInfo === 0x9
|
||||
} else if (objectType === 0x1) {
|
||||
value = readUInt(bytes, offset + 1, 1 << objectInfo)
|
||||
} else if (objectType === 0x2) {
|
||||
const realSize = 1 << objectInfo
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset + offset + 1, realSize)
|
||||
value = realSize === 4 ? view.getFloat32(0, false) : view.getFloat64(0, false)
|
||||
} else if (objectType === 0x5) {
|
||||
const { length, nextOffset } = readLength(offset, objectInfo)
|
||||
value = strFromU8(bytes.subarray(nextOffset, nextOffset + length))
|
||||
} else if (objectType === 0x6) {
|
||||
const { length, nextOffset } = readLength(offset, objectInfo)
|
||||
value = decodeUtf16Be(bytes.subarray(nextOffset, nextOffset + length * 2))
|
||||
} else if (objectType === 0xa) {
|
||||
const { length, nextOffset } = readLength(offset, objectInfo)
|
||||
const items: unknown[] = []
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const ref = readUInt(bytes, nextOffset + i * objectRefSize, objectRefSize)
|
||||
items.push(parseObject(ref))
|
||||
}
|
||||
value = items
|
||||
} else if (objectType === 0xd) {
|
||||
const { length, nextOffset } = readLength(offset, objectInfo)
|
||||
const map: Record<string, unknown> = {}
|
||||
const valuesOffset = nextOffset + length * objectRefSize
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const keyRef = readUInt(bytes, nextOffset + i * objectRefSize, objectRefSize)
|
||||
const valueRef = readUInt(bytes, valuesOffset + i * objectRefSize, objectRefSize)
|
||||
const key = parseObject(keyRef)
|
||||
if (typeof key === "string") {
|
||||
map[key] = parseObject(valueRef)
|
||||
}
|
||||
}
|
||||
value = map
|
||||
} else {
|
||||
value = null
|
||||
}
|
||||
|
||||
memo.set(index, value)
|
||||
return value
|
||||
}
|
||||
|
||||
return parseObject(topObject)
|
||||
}
|
||||
|
||||
function readUInt(bytes: Uint8Array, offset: number, length: number): number {
|
||||
let value = 0
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
value = value * 256 + bytes[offset + i]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function decodeUtf16Be(bytes: Uint8Array): string {
|
||||
let text = ""
|
||||
for (let i = 0; i + 1 < bytes.length; i += 2) {
|
||||
text += String.fromCharCode((bytes[i] << 8) | bytes[i + 1])
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function buildTeamScopedBundleId(baseBundleId: string, teamId: string): string {
|
||||
const trimmedBase = baseBundleId.trim()
|
||||
const trimmedTeam = teamId.trim()
|
||||
if (!trimmedBase || !trimmedTeam) {
|
||||
return trimmedBase
|
||||
}
|
||||
const lowerBase = trimmedBase.toLowerCase()
|
||||
const lowerTeam = trimmedTeam.toLowerCase()
|
||||
if (lowerBase.endsWith(`.${lowerTeam}`)) {
|
||||
return trimmedBase
|
||||
}
|
||||
return `${trimmedBase}.${trimmedTeam}`
|
||||
}
|
||||
|
||||
function toSignedFileName(name: string): string {
|
||||
if (!name.toLowerCase().endsWith(".ipa")) {
|
||||
return `${name}-signed.ipa`
|
||||
}
|
||||
return `${name.slice(0, -4)}-signed.ipa`
|
||||
}
|
||||
|
||||
function loadCachedSigningIdentity(
|
||||
appleId: string,
|
||||
teamId: string,
|
||||
): CachedSigningIdentityPayload | null {
|
||||
const map = loadSigningIdentityMap()
|
||||
const key = signingIdentityKey(appleId, teamId)
|
||||
const value = map[key]
|
||||
if (!value || !value.certId || !value.certPublicKeyBase64 || !value.privateKeyBase64) {
|
||||
return null
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function saveCachedSigningIdentity(
|
||||
appleId: string,
|
||||
teamId: string,
|
||||
payload: CachedSigningIdentityPayload,
|
||||
): void {
|
||||
const map = loadSigningIdentityMap()
|
||||
map[signingIdentityKey(appleId, teamId)] = payload
|
||||
window.localStorage.setItem(SIGNING_IDENTITY_STORAGE_KEY, JSON.stringify(map))
|
||||
}
|
||||
|
||||
function loadSigningIdentityMap(): StoredSigningIdentityMap {
|
||||
const raw = window.localStorage.getItem(SIGNING_IDENTITY_STORAGE_KEY)
|
||||
if (!raw) {
|
||||
return {}
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {}
|
||||
}
|
||||
return parsed as StoredSigningIdentityMap
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function signingIdentityKey(appleId: string, teamId: string): string {
|
||||
return `${appleId.trim().toLowerCase()}::${teamId.trim().toUpperCase()}`
|
||||
}
|
||||
|
||||
function bytesToBase64(value: Uint8Array): string {
|
||||
let binary = ""
|
||||
for (const byte of value) {
|
||||
binary += String.fromCharCode(byte)
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
function base64ToBytes(base64: string): Uint8Array {
|
||||
const normalized = base64.replace(/\s+/g, "")
|
||||
const binary = atob(normalized)
|
||||
const out = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
out[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function maskEmail(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
const at = trimmed.indexOf("@")
|
||||
if (at <= 1) {
|
||||
return "***"
|
||||
}
|
||||
return `${trimmed.slice(0, 2)}***${trimmed.slice(at)}`
|
||||
}
|
||||
|
||||
function findRegisteredDevice(devices: readonly Device[], normalizedUdid: string): Device | null {
|
||||
return (
|
||||
devices.find((item) => normalizeUdid(item.identifier) === normalizedUdid) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeUdid(value: string): string {
|
||||
return value.trim().toUpperCase()
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
9
frontend/src/counter.ts
Normal file
9
frontend/src/counter.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function setupCounter(element: HTMLButtonElement) {
|
||||
let counter = 0
|
||||
const setCounter = (count: number) => {
|
||||
counter = count
|
||||
element.innerHTML = `count is ${counter}`
|
||||
}
|
||||
element.addEventListener('click', () => setCounter(counter + 1))
|
||||
setCounter(0)
|
||||
}
|
||||
775
frontend/src/main.ts
Normal file
775
frontend/src/main.ts
Normal file
@@ -0,0 +1,775 @@
|
||||
import "./style.css"
|
||||
import * as webmuxdModule from "webmuxd"
|
||||
import { getAnisetteData, provisionAnisette, type AnisetteData } from "./anisette-service"
|
||||
import { signIpaWithApple } from "./apple-signing"
|
||||
import initOpensslWasm, {
|
||||
libimobiledevice_generate_pair_record,
|
||||
OpensslClient,
|
||||
} from "../../tls/openssl-wasm/pkg/openssl_wasm.js"
|
||||
|
||||
interface WebUsbTransportInstance {
|
||||
readonly isOpen: boolean
|
||||
open(): Promise<void>
|
||||
close(): Promise<void>
|
||||
send(data: ArrayBuffer): Promise<void>
|
||||
setDataHandler(handler: ((data: ArrayBuffer) => void) | null): void
|
||||
setDisconnectHandler(handler: ((reason?: unknown) => void) | null): void
|
||||
}
|
||||
|
||||
interface WebUsbTransportCtor {
|
||||
supported(): boolean
|
||||
requestAppleDevice(): Promise<WebUsbTransportInstance>
|
||||
}
|
||||
|
||||
interface PairRecord {
|
||||
hostId: string
|
||||
systemBuid: string
|
||||
hostCertificatePem: string
|
||||
hostPrivateKeyPem: string
|
||||
rootCertificatePem: string
|
||||
rootPrivateKeyPem: string
|
||||
deviceCertificatePem: string
|
||||
devicePublicKey: Uint8Array
|
||||
escrowBag?: Uint8Array
|
||||
}
|
||||
|
||||
interface StoredPairRecordPayload {
|
||||
hostId: string
|
||||
systemBuid: string
|
||||
hostCertificatePem: string
|
||||
hostPrivateKeyPem: string
|
||||
rootCertificatePem: string
|
||||
rootPrivateKeyPem: string
|
||||
deviceCertificatePem: string
|
||||
devicePublicKey: string
|
||||
escrowBag: string | null
|
||||
}
|
||||
|
||||
interface StartSessionResult {
|
||||
sessionId: string
|
||||
enableSessionSsl: boolean
|
||||
}
|
||||
|
||||
interface DirectUsbMuxClient {
|
||||
readonly isHandshakeComplete: boolean
|
||||
readonly isLockdownConnected: boolean
|
||||
readonly isAfcConnected: boolean
|
||||
readonly isInstProxyConnected: boolean
|
||||
readonly isSessionStarted: boolean
|
||||
readonly isSessionSslEnabled: boolean
|
||||
readonly isTlsActive: boolean
|
||||
readonly isPaired: boolean
|
||||
loadPairRecord(record: PairRecord | null): void
|
||||
openAndHandshake(): Promise<void>
|
||||
connectLockdown(port?: number): Promise<void>
|
||||
getOrFetchDeviceUdid(): Promise<string>
|
||||
getOrFetchDeviceName(): Promise<string | null>
|
||||
pairDevice(hostId: string, systemBuid: string): Promise<PairRecord>
|
||||
startSession(hostId: string, systemBuid: string): Promise<StartSessionResult>
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
interface DirectUsbMuxClientCtor {
|
||||
new (
|
||||
transport: WebUsbTransportInstance,
|
||||
options?: {
|
||||
log?: (message: string) => void
|
||||
onStateChange?: () => void
|
||||
lockdownLabel?: string
|
||||
tlsFactory?: {
|
||||
ensureReady?: () => Promise<void>
|
||||
createConnection(request: {
|
||||
serverName: string
|
||||
caCertificatePem: string
|
||||
certificatePem: string
|
||||
privateKeyPem: string
|
||||
}): {
|
||||
is_handshaking(): boolean
|
||||
write_plaintext(data: Uint8Array): void
|
||||
feed_tls(data: Uint8Array): void
|
||||
take_tls_out(): Uint8Array
|
||||
take_plain_out(): Uint8Array
|
||||
free(): void
|
||||
}
|
||||
}
|
||||
pairRecordFactory?: {
|
||||
createPairRecord(request: {
|
||||
devicePublicKey: Uint8Array
|
||||
hostId: string
|
||||
systemBuid: string
|
||||
}): Promise<PairRecord>
|
||||
}
|
||||
},
|
||||
): DirectUsbMuxClient
|
||||
}
|
||||
|
||||
interface SigningPreflightContext {
|
||||
anisetteData: AnisetteData
|
||||
preparedAtIso: string
|
||||
}
|
||||
|
||||
interface WasmPairRecordPayload {
|
||||
hostId: string
|
||||
systemBuid: string
|
||||
hostCertificatePem: string
|
||||
hostPrivateKeyPem: string
|
||||
rootCertificatePem: string
|
||||
rootPrivateKeyPem: string
|
||||
deviceCertificatePem: string
|
||||
}
|
||||
|
||||
const LOCKDOWN_PORT = 62078
|
||||
|
||||
const HOST_ID_STORAGE_KEY = "webmuxd:host-id"
|
||||
const SYSTEM_BUID_STORAGE_KEY = "webmuxd:system-buid"
|
||||
const PAIR_RECORDS_STORAGE_KEY = "webmuxd:pair-records-by-udid"
|
||||
const LEGACY_PAIR_RECORD_STORAGE_KEY = "webmuxd:pair-record"
|
||||
const APPLE_ID_STORAGE_KEY = "webmuxd:apple-id"
|
||||
const SIGN_BUNDLE_ID_STORAGE_KEY = "webmuxd:sign-bundle-id"
|
||||
const SIGN_DISPLAY_NAME_STORAGE_KEY = "webmuxd:sign-display-name"
|
||||
|
||||
const ENABLE_BROWSER_SIGNING_PIPELINE = true
|
||||
const SIGNING_PREFLIGHT_REQUIRED = false
|
||||
|
||||
const webmuxdModuleValue = webmuxdModule as unknown as Record<string, unknown>
|
||||
|
||||
const WebUsbTransport = resolveWebmuxdExport<WebUsbTransportCtor>(
|
||||
webmuxdModuleValue,
|
||||
"WebUsbTransport",
|
||||
)
|
||||
const WebmuxdDirectUsbMuxClient = resolveWebmuxdExport<DirectUsbMuxClientCtor>(
|
||||
webmuxdModuleValue,
|
||||
"DirectUsbMuxClient",
|
||||
)
|
||||
const webmuxdInstallIpaViaInstProxy = resolveWebmuxdExport<
|
||||
(
|
||||
client: DirectUsbMuxClient,
|
||||
ipaData: Uint8Array,
|
||||
fileName: string,
|
||||
onLog?: (message: string) => void,
|
||||
) => Promise<void>
|
||||
>(webmuxdModuleValue, "installIpaViaInstProxy")
|
||||
const webmuxdSanitizeIpaFileName = resolveWebmuxdExport<(fileName: string) => string>(
|
||||
webmuxdModuleValue,
|
||||
"sanitizeIpaFileName",
|
||||
)
|
||||
const webmuxdCreateHostId = resolveWebmuxdExport<() => string>(
|
||||
webmuxdModuleValue,
|
||||
"createHostId",
|
||||
)
|
||||
const webmuxdCreateSystemBuid = resolveWebmuxdExport<() => string>(
|
||||
webmuxdModuleValue,
|
||||
"createSystemBuid",
|
||||
)
|
||||
const webmuxdEncodeStoredPairRecord = resolveWebmuxdExport<
|
||||
(record: PairRecord) => StoredPairRecordPayload
|
||||
>(webmuxdModuleValue, "encodeStoredPairRecord")
|
||||
const webmuxdDecodeStoredPairRecord = resolveWebmuxdExport<
|
||||
(payload: StoredPairRecordPayload) => PairRecord | null
|
||||
>(webmuxdModuleValue, "decodeStoredPairRecord")
|
||||
|
||||
let opensslInitPromise: Promise<void> | null = null
|
||||
|
||||
const ensureOpensslReady = async (): Promise<void> => {
|
||||
if (!opensslInitPromise) {
|
||||
opensslInitPromise = initOpensslWasm().then(() => undefined)
|
||||
}
|
||||
await opensslInitPromise
|
||||
}
|
||||
|
||||
const app = document.querySelector<HTMLDivElement>("#app")
|
||||
if (!app) {
|
||||
throw new Error("App root is missing")
|
||||
}
|
||||
|
||||
app.innerHTML = `
|
||||
<main class="shell">
|
||||
<section class="panel">
|
||||
<p class="eyebrow">Pure Browser usbmux + lockdown</p>
|
||||
<h1>WebMuxD Direct Mode</h1>
|
||||
<p class="subline">
|
||||
Handshake with iPhone USB MUX directly, then connect lockdownd (62078).
|
||||
</p>
|
||||
<div id="ipa-drop-zone" class="drop-zone">
|
||||
Drag & drop IPA here to run full flow: Signing Preflight -> Select Device -> Session -> Upload -> Install
|
||||
</div>
|
||||
<div class="request-row">
|
||||
<label for="host-id">HostID</label>
|
||||
<input id="host-id" type="text" />
|
||||
</div>
|
||||
<div class="request-row">
|
||||
<label for="system-buid">SystemBUID</label>
|
||||
<input id="system-buid" type="text" />
|
||||
</div>
|
||||
<div class="request-row">
|
||||
<label for="apple-id">Apple ID</label>
|
||||
<input id="apple-id" type="email" autocomplete="username" />
|
||||
</div>
|
||||
<div class="request-row">
|
||||
<label for="apple-password">Apple Password</label>
|
||||
<input id="apple-password" type="password" autocomplete="current-password" />
|
||||
</div>
|
||||
<div class="request-row">
|
||||
<label for="sign-bundle-id">Sign BundleID</label>
|
||||
<input id="sign-bundle-id" type="text" placeholder="optional" />
|
||||
</div>
|
||||
<div class="request-row">
|
||||
<label for="sign-display-name">Sign DisplayName</label>
|
||||
<input id="sign-display-name" type="text" placeholder="optional" />
|
||||
</div>
|
||||
<div class="request-row">
|
||||
<label for="ipa-file">Select IPA</label>
|
||||
<input id="ipa-file" type="file" accept=".ipa,application/octet-stream" />
|
||||
</div>
|
||||
<div class="state-grid">
|
||||
<div class="state-item">
|
||||
<span class="label">WebUSB</span>
|
||||
<span id="support-state" class="value"></span>
|
||||
</div>
|
||||
<div class="state-item">
|
||||
<span class="label">Transport</span>
|
||||
<span id="transport-state" class="value"></span>
|
||||
</div>
|
||||
<div class="state-item">
|
||||
<span class="label">MUX</span>
|
||||
<span id="mux-state" class="value"></span>
|
||||
</div>
|
||||
<div class="state-item">
|
||||
<span class="label">Lockdown TCP</span>
|
||||
<span id="lockdown-state" class="value"></span>
|
||||
</div>
|
||||
<div class="state-item">
|
||||
<span class="label">Pair</span>
|
||||
<span id="pair-state" class="value"></span>
|
||||
</div>
|
||||
<div class="state-item">
|
||||
<span class="label">Lockdown Session</span>
|
||||
<span id="session-state" class="value"></span>
|
||||
</div>
|
||||
<div class="state-item">
|
||||
<span class="label">AFC</span>
|
||||
<span id="afc-state" class="value"></span>
|
||||
</div>
|
||||
<div class="state-item">
|
||||
<span class="label">InstProxy</span>
|
||||
<span id="instproxy-state" class="value"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="log-panel">
|
||||
<div class="log-head">
|
||||
<h2>Event Log</h2>
|
||||
</div>
|
||||
<pre id="log" class="log"></pre>
|
||||
</section>
|
||||
</main>
|
||||
`
|
||||
|
||||
const hostIdInput = mustGetInput("host-id")
|
||||
const systemBuidInput = mustGetInput("system-buid")
|
||||
const appleIdInput = mustGetInput("apple-id")
|
||||
const applePasswordInput = mustGetInput("apple-password")
|
||||
const signBundleIdInput = mustGetInput("sign-bundle-id")
|
||||
const signDisplayNameInput = mustGetInput("sign-display-name")
|
||||
const ipaFileInput = mustGetInput("ipa-file")
|
||||
|
||||
const supportState = mustGetElement("support-state")
|
||||
const transportState = mustGetElement("transport-state")
|
||||
const muxState = mustGetElement("mux-state")
|
||||
const lockdownState = mustGetElement("lockdown-state")
|
||||
const pairState = mustGetElement("pair-state")
|
||||
const sessionState = mustGetElement("session-state")
|
||||
const afcState = mustGetElement("afc-state")
|
||||
const instProxyState = mustGetElement("instproxy-state")
|
||||
const ipaDropZone = mustGetElement("ipa-drop-zone")
|
||||
const logView = mustGetElement("log")
|
||||
|
||||
const logLines: string[] = []
|
||||
const isSupported = WebUsbTransport.supported()
|
||||
|
||||
let directClient: DirectUsbMuxClient | null = null
|
||||
let installFlowInProgress = false
|
||||
let signingPreflightPromise: Promise<SigningPreflightContext | null> | null = null
|
||||
|
||||
hostIdInput.value = getOrCreateHostId()
|
||||
systemBuidInput.value = getOrCreateSystemBuid()
|
||||
appleIdInput.value = loadText(APPLE_ID_STORAGE_KEY) ?? ""
|
||||
applePasswordInput.value = ""
|
||||
signBundleIdInput.value = loadText(SIGN_BUNDLE_ID_STORAGE_KEY) ?? ""
|
||||
signDisplayNameInput.value = loadText(SIGN_DISPLAY_NAME_STORAGE_KEY) ?? ""
|
||||
|
||||
const addLog = (message: string): void => {
|
||||
const now = new Date()
|
||||
const time = `${now.toLocaleTimeString()}.${String(now.getMilliseconds()).padStart(3, "0")}`
|
||||
logLines.push(`[${time}] ${message}`)
|
||||
logView.textContent = logLines.slice(-200).join("\n")
|
||||
}
|
||||
|
||||
const refreshState = (): void => {
|
||||
supportState.textContent = isSupported ? "supported" : "not supported"
|
||||
transportState.textContent = directClient ? "selected" : "not selected"
|
||||
muxState.textContent = directClient?.isHandshakeComplete ? "ready" : "pending"
|
||||
lockdownState.textContent = directClient?.isLockdownConnected ? "connected" : "disconnected"
|
||||
pairState.textContent = directClient?.isPaired ? "paired" : "not paired"
|
||||
sessionState.textContent = directClient?.isSessionStarted
|
||||
? directClient.isSessionSslEnabled
|
||||
? directClient.isTlsActive
|
||||
? "started (tls active)"
|
||||
: "started (ssl required)"
|
||||
: "started"
|
||||
: "not started"
|
||||
afcState.textContent = directClient?.isAfcConnected ? "connected" : "disconnected"
|
||||
instProxyState.textContent = directClient?.isInstProxyConnected ? "connected" : "disconnected"
|
||||
}
|
||||
|
||||
const ensureClientSelected = async (): Promise<DirectUsbMuxClient> => {
|
||||
if (directClient) {
|
||||
return directClient
|
||||
}
|
||||
|
||||
const transport = await WebUsbTransport.requestAppleDevice()
|
||||
directClient = new WebmuxdDirectUsbMuxClient(transport, {
|
||||
log: addLog,
|
||||
onStateChange: refreshState,
|
||||
lockdownLabel: "webmuxd.frontend",
|
||||
tlsFactory: {
|
||||
ensureReady: ensureOpensslReady,
|
||||
createConnection: (request) => {
|
||||
return new OpensslClient(
|
||||
request.serverName,
|
||||
request.caCertificatePem,
|
||||
request.certificatePem,
|
||||
request.privateKeyPem,
|
||||
)
|
||||
},
|
||||
},
|
||||
pairRecordFactory: {
|
||||
createPairRecord: async (request) => {
|
||||
return await createPairRecord(request.devicePublicKey, request.hostId, request.systemBuid)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
addLog("Apple device selected.")
|
||||
refreshState()
|
||||
return directClient
|
||||
}
|
||||
|
||||
const getSigningCredentials = (): { appleId: string; password: string } | null => {
|
||||
const appleId = appleIdInput.value.trim()
|
||||
const password = applePasswordInput.value
|
||||
if (!appleId || !password) {
|
||||
return null
|
||||
}
|
||||
return { appleId, password }
|
||||
}
|
||||
|
||||
const prepareSigningPreflight = async (
|
||||
log: (message: string) => void,
|
||||
): Promise<SigningPreflightContext | null> => {
|
||||
if (signingPreflightPromise) {
|
||||
return signingPreflightPromise
|
||||
}
|
||||
|
||||
signingPreflightPromise = (async () => {
|
||||
try {
|
||||
log("Signing preflight: provisioning anisette...")
|
||||
await provisionAnisette()
|
||||
const anisetteData = await getAnisetteData()
|
||||
const context: SigningPreflightContext = {
|
||||
anisetteData,
|
||||
preparedAtIso: new Date().toISOString(),
|
||||
}
|
||||
log(
|
||||
`Signing preflight ready: machineID=${shortToken(anisetteData.machineID)}, locale=${anisetteData.locale}, timezone=${anisetteData.timeZone}`,
|
||||
)
|
||||
return context
|
||||
} catch (error) {
|
||||
const message = formatError(error)
|
||||
log(`Signing preflight failed: ${message}`)
|
||||
if (SIGNING_PREFLIGHT_REQUIRED) {
|
||||
throw error
|
||||
}
|
||||
log("Signing preflight skipped (non-blocking mode).")
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
try {
|
||||
return await signingPreflightPromise
|
||||
} finally {
|
||||
signingPreflightPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
const prepareIpaForInstall = async (
|
||||
ipaFile: File,
|
||||
log: (message: string) => void,
|
||||
device: { udid: string; name?: string },
|
||||
): Promise<{ installFile: File; signingContext: SigningPreflightContext | null }> => {
|
||||
const signingContext = await prepareSigningPreflight(log)
|
||||
if (signingContext) {
|
||||
log(`Signing preflight timestamp: ${signingContext.preparedAtIso}`)
|
||||
}
|
||||
|
||||
if (!ENABLE_BROWSER_SIGNING_PIPELINE) {
|
||||
log("Signing stage: browser resign pipeline is disabled, using original IPA.")
|
||||
return { installFile: ipaFile, signingContext }
|
||||
}
|
||||
if (!signingContext) {
|
||||
log("Signing stage: anisette is unavailable, using original IPA.")
|
||||
return { installFile: ipaFile, signingContext: null }
|
||||
}
|
||||
|
||||
const credentials = getSigningCredentials()
|
||||
if (!credentials) {
|
||||
log("Signing stage: Apple ID/password not set, using original IPA.")
|
||||
return { installFile: ipaFile, signingContext }
|
||||
}
|
||||
|
||||
const bundleIdOverride = signBundleIdInput.value.trim()
|
||||
const displayNameOverride = signDisplayNameInput.value.trim()
|
||||
|
||||
const signingResult = await signIpaWithApple({
|
||||
ipaFile,
|
||||
anisetteData: signingContext.anisetteData,
|
||||
credentials,
|
||||
deviceUdid: device.udid,
|
||||
deviceName: device.name,
|
||||
bundleIdOverride: bundleIdOverride.length > 0 ? bundleIdOverride : undefined,
|
||||
displayNameOverride: displayNameOverride.length > 0 ? displayNameOverride : undefined,
|
||||
onLog: log,
|
||||
})
|
||||
|
||||
log(
|
||||
`Signing stage: signed IPA ready (${signingResult.signedFile.name}), bundleId=${signingResult.outputBundleId}, team=${signingResult.teamId}.`,
|
||||
)
|
||||
|
||||
return { installFile: signingResult.signedFile, signingContext }
|
||||
}
|
||||
|
||||
const installIpaViaInstProxy = async (
|
||||
client: DirectUsbMuxClient,
|
||||
ipaFile: File,
|
||||
log: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
const rawData = new Uint8Array(await ipaFile.arrayBuffer())
|
||||
const safeFileName = webmuxdSanitizeIpaFileName(ipaFile.name)
|
||||
await webmuxdInstallIpaViaInstProxy(client, rawData, safeFileName, log)
|
||||
}
|
||||
|
||||
const runFullInstallFlow = async (ipaFile: File): Promise<void> => {
|
||||
if (installFlowInProgress) {
|
||||
throw new Error("Install flow is already running")
|
||||
}
|
||||
installFlowInProgress = true
|
||||
|
||||
try {
|
||||
addLog(`Full install start: ${ipaFile.name} (${ipaFile.size} bytes).`)
|
||||
|
||||
const client = await ensureClientSelected()
|
||||
|
||||
let hostId = hostIdInput.value.trim()
|
||||
let systemBuid = systemBuidInput.value.trim()
|
||||
|
||||
if (hostId.length === 0) {
|
||||
hostId = getOrCreateHostId()
|
||||
hostIdInput.value = hostId
|
||||
}
|
||||
if (systemBuid.length === 0) {
|
||||
systemBuid = getOrCreateSystemBuid()
|
||||
systemBuidInput.value = systemBuid
|
||||
}
|
||||
|
||||
saveText(HOST_ID_STORAGE_KEY, hostId)
|
||||
saveText(SYSTEM_BUID_STORAGE_KEY, systemBuid)
|
||||
|
||||
if (!client.isHandshakeComplete) {
|
||||
await client.openAndHandshake()
|
||||
}
|
||||
if (!client.isLockdownConnected) {
|
||||
await client.connectLockdown(LOCKDOWN_PORT)
|
||||
}
|
||||
|
||||
const deviceUdid = await client.getOrFetchDeviceUdid()
|
||||
const deviceName = await client.getOrFetchDeviceName()
|
||||
addLog(`Device UDID: ${deviceUdid}`)
|
||||
if (deviceName) {
|
||||
addLog(`Device Name: ${deviceName}`)
|
||||
}
|
||||
|
||||
const prepared = await prepareIpaForInstall(ipaFile, addLog, {
|
||||
udid: deviceUdid,
|
||||
name: deviceName ?? undefined,
|
||||
})
|
||||
|
||||
const storedPair = loadPairRecordForUdid(deviceUdid)
|
||||
if (storedPair && !client.isPaired) {
|
||||
client.loadPairRecord(storedPair)
|
||||
hostId = storedPair.hostId
|
||||
systemBuid = storedPair.systemBuid
|
||||
hostIdInput.value = hostId
|
||||
systemBuidInput.value = systemBuid
|
||||
saveText(HOST_ID_STORAGE_KEY, hostId)
|
||||
saveText(SYSTEM_BUID_STORAGE_KEY, systemBuid)
|
||||
addLog(`Loaded pair record for device ${deviceUdid}.`)
|
||||
}
|
||||
|
||||
if (!client.isPaired) {
|
||||
const pairResult = await client.pairDevice(hostId, systemBuid)
|
||||
savePairRecordForUdid(deviceUdid, pairResult)
|
||||
addLog(`Pair success and pair record saved for ${deviceUdid}.`)
|
||||
}
|
||||
|
||||
if (!client.isSessionStarted) {
|
||||
const session = await client.startSession(hostId, systemBuid)
|
||||
addLog(
|
||||
`StartSession success: SessionID=${session.sessionId}, EnableSessionSSL=${String(session.enableSessionSsl)}`,
|
||||
)
|
||||
}
|
||||
|
||||
await installIpaViaInstProxy(client, prepared.installFile, addLog)
|
||||
} finally {
|
||||
installFlowInProgress = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectedIpaFile = async (file: File): Promise<void> => {
|
||||
addLog(`IPA selected: ${file.name} (${file.size} bytes).`)
|
||||
refreshState()
|
||||
|
||||
try {
|
||||
await runFullInstallFlow(file)
|
||||
} catch (error) {
|
||||
addLog(`Install IPA failed: ${formatError(error)}`)
|
||||
} finally {
|
||||
refreshState()
|
||||
}
|
||||
}
|
||||
|
||||
appleIdInput.addEventListener("change", () => {
|
||||
saveText(APPLE_ID_STORAGE_KEY, appleIdInput.value.trim())
|
||||
})
|
||||
|
||||
signBundleIdInput.addEventListener("change", () => {
|
||||
saveText(SIGN_BUNDLE_ID_STORAGE_KEY, signBundleIdInput.value.trim())
|
||||
})
|
||||
|
||||
signDisplayNameInput.addEventListener("change", () => {
|
||||
saveText(SIGN_DISPLAY_NAME_STORAGE_KEY, signDisplayNameInput.value.trim())
|
||||
})
|
||||
|
||||
ipaFileInput.addEventListener("change", async () => {
|
||||
const file = ipaFileInput.files && ipaFileInput.files.length > 0 ? ipaFileInput.files[0] : null
|
||||
if (!file) {
|
||||
addLog("IPA selection cleared.")
|
||||
refreshState()
|
||||
return
|
||||
}
|
||||
await handleSelectedIpaFile(file)
|
||||
})
|
||||
|
||||
ipaDropZone.addEventListener("dragenter", (event) => {
|
||||
event.preventDefault()
|
||||
ipaDropZone.classList.add("dragover")
|
||||
})
|
||||
|
||||
ipaDropZone.addEventListener("dragover", (event) => {
|
||||
event.preventDefault()
|
||||
ipaDropZone.classList.add("dragover")
|
||||
})
|
||||
|
||||
ipaDropZone.addEventListener("dragleave", () => {
|
||||
ipaDropZone.classList.remove("dragover")
|
||||
})
|
||||
|
||||
ipaDropZone.addEventListener("drop", async (event) => {
|
||||
event.preventDefault()
|
||||
ipaDropZone.classList.remove("dragover")
|
||||
|
||||
const file = event.dataTransfer?.files?.[0] ?? null
|
||||
if (!file) {
|
||||
addLog("Drop ignored: no file.")
|
||||
return
|
||||
}
|
||||
await handleSelectedIpaFile(file)
|
||||
})
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
void directClient?.close()
|
||||
})
|
||||
|
||||
addLog("Demo ready.")
|
||||
refreshState()
|
||||
|
||||
function mustGetElement(id: string): HTMLElement {
|
||||
const element = document.getElementById(id)
|
||||
if (!element) {
|
||||
throw new Error(`Element #${id} not found`)
|
||||
}
|
||||
return element
|
||||
}
|
||||
|
||||
function mustGetInput(id: string): HTMLInputElement {
|
||||
const element = document.getElementById(id)
|
||||
if (!element || !(element instanceof HTMLInputElement)) {
|
||||
throw new Error(`Input #${id} not found`)
|
||||
}
|
||||
return element
|
||||
}
|
||||
|
||||
function loadText(key: string): string | null {
|
||||
return window.localStorage.getItem(key)
|
||||
}
|
||||
|
||||
function saveText(key: string, value: string): void {
|
||||
window.localStorage.setItem(key, value)
|
||||
}
|
||||
|
||||
function getOrCreateHostId(): string {
|
||||
const existing = loadText(HOST_ID_STORAGE_KEY)
|
||||
if (existing && existing.trim().length > 0) {
|
||||
return existing
|
||||
}
|
||||
const created = webmuxdCreateHostId()
|
||||
saveText(HOST_ID_STORAGE_KEY, created)
|
||||
return created
|
||||
}
|
||||
|
||||
function getOrCreateSystemBuid(): string {
|
||||
const existing = loadText(SYSTEM_BUID_STORAGE_KEY)
|
||||
if (existing && existing.trim().length > 0) {
|
||||
return existing
|
||||
}
|
||||
const created = webmuxdCreateSystemBuid()
|
||||
saveText(SYSTEM_BUID_STORAGE_KEY, created)
|
||||
return created
|
||||
}
|
||||
|
||||
function readPairRecordMap(): Record<string, StoredPairRecordPayload> {
|
||||
const text = loadText(PAIR_RECORDS_STORAGE_KEY)
|
||||
if (!text) {
|
||||
return {}
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {}
|
||||
}
|
||||
return parsed as Record<string, StoredPairRecordPayload>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writePairRecordMap(map: Record<string, StoredPairRecordPayload>): void {
|
||||
saveText(PAIR_RECORDS_STORAGE_KEY, JSON.stringify(map))
|
||||
}
|
||||
|
||||
function savePairRecordForUdid(udid: string, record: PairRecord): void {
|
||||
const normalizedUdid = udid.trim()
|
||||
if (normalizedUdid.length === 0) {
|
||||
return
|
||||
}
|
||||
const map = readPairRecordMap()
|
||||
map[normalizedUdid] = webmuxdEncodeStoredPairRecord(record)
|
||||
writePairRecordMap(map)
|
||||
}
|
||||
|
||||
function loadLegacyPairRecord(): PairRecord | null {
|
||||
const text = loadText(LEGACY_PAIR_RECORD_STORAGE_KEY)
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text) as StoredPairRecordPayload
|
||||
return webmuxdDecodeStoredPairRecord(parsed)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function loadPairRecordForUdid(udid: string): PairRecord | null {
|
||||
const normalizedUdid = udid.trim()
|
||||
if (normalizedUdid.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const map = readPairRecordMap()
|
||||
const fromMap = map[normalizedUdid]
|
||||
if (fromMap) {
|
||||
try {
|
||||
return webmuxdDecodeStoredPairRecord(fromMap)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const legacy = loadLegacyPairRecord()
|
||||
if (legacy) {
|
||||
savePairRecordForUdid(normalizedUdid, legacy)
|
||||
window.localStorage.removeItem(LEGACY_PAIR_RECORD_STORAGE_KEY)
|
||||
}
|
||||
return legacy
|
||||
}
|
||||
|
||||
async function createPairRecord(
|
||||
devicePublicKeyBytes: Uint8Array,
|
||||
hostId: string,
|
||||
systemBuid: string,
|
||||
): Promise<PairRecord> {
|
||||
await ensureOpensslReady()
|
||||
const payloadText = libimobiledevice_generate_pair_record(
|
||||
new Uint8Array(devicePublicKeyBytes),
|
||||
hostId,
|
||||
systemBuid,
|
||||
)
|
||||
const payload = JSON.parse(payloadText) as WasmPairRecordPayload
|
||||
return {
|
||||
hostId: payload.hostId,
|
||||
systemBuid: payload.systemBuid,
|
||||
hostCertificatePem: normalizePem(payload.hostCertificatePem),
|
||||
hostPrivateKeyPem: normalizePem(payload.hostPrivateKeyPem),
|
||||
rootCertificatePem: normalizePem(payload.rootCertificatePem),
|
||||
rootPrivateKeyPem: normalizePem(payload.rootPrivateKeyPem),
|
||||
deviceCertificatePem: normalizePem(payload.deviceCertificatePem),
|
||||
devicePublicKey: new Uint8Array(devicePublicKeyBytes),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePem(value: string): string {
|
||||
const normalized = value.replace(/\0/g, "").replace(/\r\n/g, "\n").trim()
|
||||
return `${normalized}\n`
|
||||
}
|
||||
|
||||
function shortToken(value: string): string {
|
||||
const text = value.trim()
|
||||
if (text.length <= 10) {
|
||||
return text
|
||||
}
|
||||
return `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function resolveWebmuxdExport<T>(moduleValue: Record<string, unknown>, key: string): T {
|
||||
const direct = moduleValue[key]
|
||||
if (direct !== undefined) {
|
||||
return direct as T
|
||||
}
|
||||
|
||||
const defaultValue = moduleValue.default
|
||||
if (defaultValue && typeof defaultValue === "object") {
|
||||
const fromDefault = (defaultValue as Record<string, unknown>)[key]
|
||||
if (fromDefault !== undefined) {
|
||||
return fromDefault as T
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`webmuxd export ${key} is unavailable`)
|
||||
}
|
||||
2
frontend/src/node-forge.d.ts
vendored
Normal file
2
frontend/src/node-forge.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare module "node-forge"
|
||||
|
||||
196
frontend/src/style.css
Normal file
196
frontend/src/style.css
Normal file
@@ -0,0 +1,196 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap");
|
||||
|
||||
:root {
|
||||
--bg-top: #f7f1e6;
|
||||
--bg-bottom: #e8dcc3;
|
||||
--panel: rgba(255, 249, 238, 0.82);
|
||||
--ink: #1f2a36;
|
||||
--ink-soft: #5c6672;
|
||||
--accent: #d64f2a;
|
||||
--accent-2: #0c7f7a;
|
||||
--border: rgba(31, 42, 54, 0.16);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at 14% 16%, rgba(214, 79, 42, 0.3), transparent 42%),
|
||||
radial-gradient(circle at 82% 8%, rgba(12, 127, 122, 0.25), transparent 36%),
|
||||
linear-gradient(170deg, var(--bg-top), var(--bg-bottom));
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1160px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 48px;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.log-panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: var(--accent-2);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 10px 0 4px;
|
||||
font-size: clamp(1.8rem, 5vw, 2.8rem);
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.subline {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
margin-top: 18px;
|
||||
border: 2px dashed rgba(12, 127, 122, 0.45);
|
||||
border-radius: 14px;
|
||||
padding: 16px 14px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-2);
|
||||
background: rgba(12, 127, 122, 0.08);
|
||||
transition: border-color 120ms ease, background-color 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.drop-zone.dragover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(214, 79, 42, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.request-row {
|
||||
margin-top: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.request-row label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.request-row input {
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
color: var(--ink);
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, monospace;
|
||||
}
|
||||
|
||||
.request-row input:focus {
|
||||
outline: 2px solid rgba(12, 127, 122, 0.24);
|
||||
border-color: rgba(12, 127, 122, 0.45);
|
||||
}
|
||||
|
||||
.state-grid {
|
||||
margin-top: 20px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.state-item {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-height: 430px;
|
||||
}
|
||||
|
||||
.log-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.log {
|
||||
margin: 12px 0 0;
|
||||
padding: 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(28, 36, 46, 0.96);
|
||||
color: #d5f4ef;
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.request-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.state-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
1
frontend/src/typescript.svg
Normal file
1
frontend/src/typescript.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"></path><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
4
frontend/src/webmuxd-shim.d.ts
vendored
Normal file
4
frontend/src/webmuxd-shim.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module "webmuxd" {
|
||||
const webmuxd: Record<string, unknown>
|
||||
export default webmuxd
|
||||
}
|
||||
Reference in New Issue
Block a user