diff --git a/frontend/src/apple-signing.ts b/frontend/src/apple-signing.ts index 2db7cf6..1d729c2 100644 --- a/frontend/src/apple-signing.ts +++ b/frontend/src/apple-signing.ts @@ -51,6 +51,37 @@ export interface AppleSigningResult { teamId: string } +export interface AppleDeveloperSession { + anisetteData: AnisetteData + dsid: string + authToken: string +} + +export interface AppleDeveloperContext { + appleId: string + session: AppleDeveloperSession + team: Team + certificates: Certificate[] + devices: Device[] +} + +export interface AppleDeveloperLoginRequest { + anisetteData: AnisetteData + credentials: AppleSigningCredentials + onLog?: (message: string) => void + onTwoFactorRequired?: (submitCode: (code: string) => void) => void +} + +export interface AppleSigningWithContextRequest { + ipaFile: File + context: AppleDeveloperContext + deviceUdid: string + deviceName?: string + bundleIdOverride?: string + displayNameOverride?: string + onLog: (message: string) => void +} + let appleApiInstance: AppleAPI | null = null function getAppleApi(): AppleAPI { @@ -72,10 +103,108 @@ function getAppleApi(): AppleAPI { return appleApiInstance } +export async function loginAppleDeveloperAccount( + request: AppleDeveloperLoginRequest, +): Promise { + const appleId = request.credentials.appleId.trim() + const password = request.credentials.password + if (!appleId || !password) { + throw new Error("Cannot login Apple account: Apple ID or password is empty") + } + + const log = request.onLog ?? (() => undefined) + log(`Login stage: authenticating Apple account ${maskEmail(appleId)}...`) + + const api = getAppleApi() + const { session } = await api.authenticate( + appleId, + password, + request.anisetteData, + (submitCode) => { + if (request.onTwoFactorRequired) { + request.onTwoFactorRequired((code) => { + const normalized = code.trim() + if (normalized.length === 0) { + throw new Error("2FA code is required") + } + submitCode(normalized) + }) + return + } + const code = window.prompt("Apple 2FA code") + if (!code || code.trim().length === 0) { + throw new Error("2FA code is required") + } + submitCode(code.trim()) + }, + ) + + log("Login stage: fetching team/certificates/devices...") + const team = await api.fetchTeam(session) + const [certificates, devices] = await Promise.all([ + api.fetchCertificates(session, team), + api.fetchDevices(session, team).catch(() => [] as Device[]), + ]) + + log( + `Login stage: team=${team.identifier} (${team.name}), certs=${certificates.length}, devices=${devices.length}.`, + ) + + return { + appleId, + session, + team, + certificates, + devices, + } +} + +export async function refreshAppleDeveloperContext( + context: AppleDeveloperContext, + onLog?: (message: string) => void, +): Promise { + const log = onLog ?? (() => undefined) + const api = getAppleApi() + log("Signing stage: refreshing team/certificates/devices...") + const team = await api.fetchTeam(context.session) + const [certificates, devices] = await Promise.all([ + api.fetchCertificates(context.session, team), + api.fetchDevices(context.session, team).catch(() => [] as Device[]), + ]) + log( + `Signing stage: refreshed team=${team.identifier}, certs=${certificates.length}, devices=${devices.length}.`, + ) + return { + ...context, + team, + certificates, + devices, + } +} + export async function signIpaWithApple( request: AppleSigningRequest, ): Promise { - const { ipaFile, anisetteData, credentials, onLog } = request + const context = await loginAppleDeveloperAccount({ + anisetteData: request.anisetteData, + credentials: request.credentials, + onLog: request.onLog, + }) + return await signIpaWithAppleContext({ + ipaFile: request.ipaFile, + context, + deviceUdid: request.deviceUdid, + deviceName: request.deviceName, + bundleIdOverride: request.bundleIdOverride, + displayNameOverride: request.displayNameOverride, + onLog: request.onLog, + }) +} + +export async function signIpaWithAppleContext( + request: AppleSigningWithContextRequest, +): Promise { + const { ipaFile, context, onLog } = request const ipaData = new Uint8Array(await ipaFile.arrayBuffer()) const ipaInfo = readIpaInfo(ipaData) @@ -84,41 +213,32 @@ export async function signIpaWithApple( 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) + const team = context.team 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) + const identity = await ensureSigningIdentity( + api, + context.session, + team, + context.appleId, + onLog, + ) await ensureDeviceRegistered( api, - session, + context.session, team, request.deviceUdid, request.deviceName, onLog, ) - const appId = await ensureAppId(api, session, team, finalBundleId, onLog) + const appId = await ensureAppId(api, context.session, team, finalBundleId, onLog) onLog("Signing stage: fetching provisioning profile...") - const provisioningProfile = await api.fetchProvisioningProfile(session, team, appId) + const provisioningProfile = await api.fetchProvisioningProfile(context.session, team, appId) onLog("Signing stage: resigning IPA in browser...") const signed = await signIPA({ diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 0ea5dde..e9e3a96 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,7 +1,12 @@ import "./style.css" import * as webmuxdModule from "webmuxd" import { getAnisetteData, provisionAnisette, type AnisetteData } from "./anisette-service" -import { signIpaWithApple } from "./apple-signing" +import { + loginAppleDeveloperAccount, + refreshAppleDeveloperContext, + signIpaWithAppleContext, + type AppleDeveloperContext, +} from "./apple-signing" import initOpensslWasm, { libimobiledevice_generate_pair_record, OpensslClient, @@ -53,8 +58,6 @@ interface StartSessionResult { interface DirectUsbMuxClient { readonly isHandshakeComplete: boolean readonly isLockdownConnected: boolean - readonly isAfcConnected: boolean - readonly isInstProxyConnected: boolean readonly isSessionStarted: boolean readonly isSessionSslEnabled: boolean readonly isTlsActive: boolean @@ -103,11 +106,6 @@ interface DirectUsbMuxClientCtor { ): DirectUsbMuxClient } -interface SigningPreflightContext { - anisetteData: AnisetteData - preparedAtIso: string -} - interface WasmPairRecordPayload { hostId: string systemBuid: string @@ -118,6 +116,26 @@ interface WasmPairRecordPayload { deviceCertificatePem: string } +interface PairedDeviceInfo { + udid: string + name: string | null +} + +type ProgressSource = "sign" | "install" + +interface ProgressUpdate { + percent: number + status: string + source: ProgressSource +} + +interface StoredAccountSummary { + appleId: string + teamId: string + teamName: string + updatedAtIso: string +} + const LOCKDOWN_PORT = 62078 const HOST_ID_STORAGE_KEY = "webmuxd:host-id" @@ -125,11 +143,8 @@ 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 APPLE_ACCOUNT_SUMMARY_STORAGE_KEY = "webmuxd:apple-account-summary" +const DEMO_MODE_STORAGE_KEY = "webmuxd:demo-mode" const webmuxdModuleValue = webmuxdModule as unknown as Record @@ -183,143 +198,111 @@ if (!app) { } app.innerHTML = ` -
+
-

Pure Browser usbmux + lockdown

-

WebMuxD Direct Mode

-

- Handshake with iPhone USB MUX directly, then connect lockdownd (62078). -

-
- Drag & drop IPA here to run full flow: Signing Preflight -> Select Device -> Session -> Upload -> Install -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- +
+

altstore web

+ +
+ +
-
-
- WebUSB - + + + + +
+ + device udid + - + +
+ +
status: idle
+ +
+
+ sign/install progress + idle
-
- Transport - + -
- MUX - -
-
- Lockdown TCP - -
-
- Pair - -
-
- Lockdown Session - -
-
- AFC - -
-
- InstProxy - -
-
-
-
-
-

Event Log

-
-

+      
+ +
log...
` -const hostIdInput = mustGetInput("host-id") -const systemBuidInput = mustGetInput("system-buid") +const isSupported = WebUsbTransport.supported() + 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 demoModeToggle = mustGetInput("demo-mode-toggle") -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 loginSignButton = mustGetButton("login-sign-btn") +const pairDeviceButton = mustGetButton("pair-device-btn") +const installButton = mustGetButton("install-btn") + +const dropArea = mustGetElement("ipa-drop-zone") +const dropLabel = mustGetElement("drop-label") +const deviceUdidView = mustGetElement("device-udid") +const statusLine = mustGetElement("status-line") +const installProgressTextView = mustGetElement("install-progress-text") +const installProgressBarView = mustGetElement("install-progress-bar") const logView = mustGetElement("log") const logLines: string[] = [] -const isSupported = WebUsbTransport.supported() let directClient: DirectUsbMuxClient | null = null -let installFlowInProgress = false -let signingPreflightPromise: Promise | null = null +let pairedDeviceInfo: PairedDeviceInfo | null = null +let selectedIpaFile: File | null = null + +let anisetteData: AnisetteData | null = null +let loginContext: AppleDeveloperContext | null = null + +let preparedSignedIpa: File | null = null +let preparedSourceKey: string | null = null + +let busyPairing = false +let busyLoginSign = false +let busyInstall = false +let demoModeEnabled = loadText(DEMO_MODE_STORAGE_KEY) === "1" +let installProgressPercent = 0 +let installProgressStatus = "idle" +let installProgressIncludesSigning = false -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 progress = parseProgressFromLog(message) + if (progress) { + applyProgressUpdate(progress) + } + 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 safeMessage = demoModeEnabled ? sanitizeDemoLogText(message) : message + logLines.push(`[${time}] ${safeMessage}`) + renderLogView() } -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 clearPreparedSigned = (): void => { + preparedSignedIpa = null + preparedSourceKey = null } const ensureClientSelected = async (): Promise => { @@ -330,7 +313,7 @@ const ensureClientSelected = async (): Promise => { const transport = await WebUsbTransport.requestAppleDevice() directClient = new WebmuxdDirectUsbMuxClient(transport, { log: addLog, - onStateChange: refreshState, + onStateChange: refreshUi, lockdownLabel: "webmuxd.frontend", tlsFactory: { ensureReady: ensureOpensslReady, @@ -350,257 +333,486 @@ const ensureClientSelected = async (): Promise => { }, }) - addLog("Apple device selected.") - refreshState() + addLog("device selected from browser popup") + refreshUi() return directClient } -const getSigningCredentials = (): { appleId: string; password: string } | null => { - const appleId = appleIdInput.value.trim() - const password = applePasswordInput.value - if (!appleId || !password) { - return null +const pairDeviceFlow = async (): Promise => { + if (busyPairing) { + return } - return { appleId, password } -} - -const prepareSigningPreflight = async ( - log: (message: string) => void, -): Promise => { - 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 - } - })() + busyPairing = true + refreshUi() 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 => { - const rawData = new Uint8Array(await ipaFile.arrayBuffer()) - const safeFileName = webmuxdSanitizeIpaFileName(ipaFile.name) - await webmuxdInstallIpaViaInstProxy(client, rawData, safeFileName, log) -} - -const runFullInstallFlow = async (ipaFile: File): Promise => { - 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) { + addLog("pair: opening mux handshake...") await client.openAndHandshake() } if (!client.isLockdownConnected) { + addLog("pair: connecting lockdownd...") 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 udid = await client.getOrFetchDeviceUdid() + const name = await client.getOrFetchDeviceName() - const prepared = await prepareIpaForInstall(ipaFile, addLog, { - udid: deviceUdid, - name: deviceName ?? undefined, - }) + let hostId = getOrCreateHostId() + let systemBuid = getOrCreateSystemBuid() - const storedPair = loadPairRecordForUdid(deviceUdid) + const storedPair = loadPairRecordForUdid(udid) 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}.`) + addLog(`pair: loaded local pair record for ${udid}`) } if (!client.isPaired) { + addLog("pair: creating pair record...") const pairResult = await client.pairDevice(hostId, systemBuid) - savePairRecordForUdid(deviceUdid, pairResult) - addLog(`Pair success and pair record saved for ${deviceUdid}.`) + savePairRecordForUdid(udid, pairResult) + addLog("pair: success") } if (!client.isSessionStarted) { const session = await client.startSession(hostId, systemBuid) - addLog( - `StartSession success: SessionID=${session.sessionId}, EnableSessionSSL=${String(session.enableSessionSsl)}`, - ) + addLog(`pair: session ready, ssl=${String(session.enableSessionSsl)}`) } - await installIpaViaInstProxy(client, prepared.installFile, addLog) + const changed = pairedDeviceInfo?.udid !== udid + pairedDeviceInfo = { udid, name } + if (changed) { + clearPreparedSigned() + } + addLog(`pair: udid=${udid}${name ? ` (${name})` : ""}`) } finally { - installFlowInProgress = false + busyPairing = false + refreshUi() } } -const handleSelectedIpaFile = async (file: File): Promise => { - addLog(`IPA selected: ${file.name} (${file.size} bytes).`) - refreshState() +const ensureAnisetteData = async (): Promise => { + if (anisetteData) { + return anisetteData + } + addLog("login: init anisette...") + await provisionAnisette() + anisetteData = await getAnisetteData() + addLog(`login: anisette ready (${shortToken(anisetteData.machineID)})`) + return anisetteData +} + +const loginAndSignFlow = async (): Promise => { + if (busyLoginSign) { + return + } + busyLoginSign = true + let didSign = false + setInstallProgress(0, "starting") + refreshUi() try { - await runFullInstallFlow(file) - } catch (error) { - addLog(`Install IPA failed: ${formatError(error)}`) + const appleId = appleIdInput.value.trim() + const password = applePasswordInput.value + if (!appleId || !password) { + throw new Error("please input email and password") + } + + saveText(APPLE_ID_STORAGE_KEY, appleId) + + const anisette = await ensureAnisetteData() + addLog("login: authenticating Apple account...") + const context = await loginAppleDeveloperAccount({ + anisetteData: anisette, + credentials: { appleId, password }, + onLog: addLog, + }) + + loginContext = await refreshAppleDeveloperContext(context, addLog) + persistAccountSummary(loginContext) + clearPreparedSigned() + addLog("login: account ready") + + if (selectedIpaFile && pairedDeviceInfo) { + await signSelectedIpa() + didSign = true + } else { + addLog("login: done. pair device and select ipa to complete signing") + } } finally { - refreshState() + busyLoginSign = false + if (!didSign) { + setInstallProgress(0, "idle") + } + refreshUi() } } +const signSelectedIpa = async (): Promise => { + if (!selectedIpaFile) { + throw new Error("no ipa selected") + } + if (!loginContext) { + throw new Error("not logged in") + } + if (!pairedDeviceInfo) { + throw new Error("device not paired") + } + + const refreshed = await refreshAppleDeveloperContext(loginContext, addLog) + loginContext = refreshed + persistAccountSummary(refreshed) + + addLog("sign: preparing ipa...") + const result = await signIpaWithAppleContext({ + ipaFile: selectedIpaFile, + context: refreshed, + deviceUdid: pairedDeviceInfo.udid, + deviceName: pairedDeviceInfo.name ?? undefined, + onLog: addLog, + }) + + preparedSignedIpa = result.signedFile + preparedSourceKey = buildPreparedSourceKey( + selectedIpaFile, + pairedDeviceInfo.udid, + refreshed.team.identifier, + ) + addLog(`sign: done -> ${preparedSignedIpa.name}`) + return preparedSignedIpa +} + +const installFlow = async (): Promise => { + if (busyInstall) { + return + } + busyInstall = true + installProgressIncludesSigning = false + setInstallProgress(0, "starting") + refreshUi() + + try { + if (!selectedIpaFile) { + throw new Error("please drag/select ipa first") + } + if (!loginContext) { + throw new Error("please login first") + } + if (!pairedDeviceInfo) { + throw new Error("please pair device first") + } + + const client = await ensureClientSelected() + if (!client.isSessionStarted) { + await pairDeviceFlow() + } + + const currentSourceKey = buildPreparedSourceKey( + selectedIpaFile, + pairedDeviceInfo.udid, + loginContext.team.identifier, + ) + installProgressIncludesSigning = !preparedSignedIpa || preparedSourceKey !== currentSourceKey + if (installProgressIncludesSigning) { + await signSelectedIpa() + } + + const upload = preparedSignedIpa + if (!upload) { + throw new Error("signed ipa is missing") + } + + addLog("install: uploading and installing...") + const bytes = new Uint8Array(await upload.arrayBuffer()) + const safeName = webmuxdSanitizeIpaFileName(upload.name) + await webmuxdInstallIpaViaInstProxy(client, bytes, safeName, addLog) + addLog("install: complete") + setInstallProgress(100, "complete") + } catch (error) { + setInstallProgress(0, "failed") + throw error + } finally { + busyInstall = false + installProgressIncludesSigning = false + refreshUi() + } +} + +const refreshUi = (): void => { + const summary = demoModeEnabled ? "hidden" : loadAccountSummaryText() + const ipaText = demoModeEnabled + ? selectedIpaFile + ? "selected" + : "none" + : selectedIpaFile + ? `${selectedIpaFile.name} (${selectedIpaFile.size} bytes)` + : "none" + const signedText = demoModeEnabled ? (preparedSignedIpa ? "prepared" : "none") : preparedSignedIpa ? preparedSignedIpa.name : "none" + + deviceUdidView.textContent = demoModeEnabled ? "hidden" : pairedDeviceInfo?.udid ?? "-" + dropLabel.textContent = demoModeEnabled + ? selectedIpaFile + ? "ipa selected" + : "drag or select ipa" + : selectedIpaFile + ? selectedIpaFile.name + : "drag or select ipa" + + statusLine.textContent = demoModeEnabled + ? "demo mode enabled | all sensitive details hidden" + : `webusb=${isSupported ? "ok" : "no"} | device=${pairedDeviceInfo ? "paired" : "-"} | ipa=${ipaText} | account=${summary} | signed=${signedText}` + + pairDeviceButton.disabled = busyPairing || busyInstall || !isSupported + loginSignButton.disabled = + busyPairing || busyLoginSign || busyInstall || appleIdInput.value.trim().length === 0 || applePasswordInput.value.length === 0 + installButton.disabled = busyPairing || busyLoginSign || busyInstall || !selectedIpaFile || !pairedDeviceInfo || !loginContext + refreshInstallProgressUi() +} + +const renderLogView = (): void => { + logView.textContent = logLines.slice(-200).join("\n") +} + +const setInstallProgress = (percent: number, status: string): void => { + installProgressPercent = Math.max(0, Math.min(100, Math.round(percent))) + installProgressStatus = status + refreshInstallProgressUi() +} + +const refreshInstallProgressUi = (): void => { + installProgressBarView.style.width = `${installProgressPercent}%` + const text = busyInstall || busyLoginSign + ? `${installProgressStatus} · ${installProgressPercent}%` + : installProgressPercent === 0 + ? "idle" + : `${installProgressStatus} · ${installProgressPercent}%` + installProgressTextView.textContent = text +} + +const parseInstallProgress = (message: string): ProgressUpdate | null => { + const statusMatch = message.match(/InstProxy status:\s*([^,]+)(?:,|$)/i) + if (!statusMatch) { + return null + } + const status = statusMatch[1].trim() + const percentMatch = message.match(/Percent=(\d{1,3})%/i) + if (percentMatch) { + return { source: "install", percent: Number(percentMatch[1]), status } + } + if (status.toLowerCase() === "complete") { + return { source: "install", percent: 100, status } + } + return { source: "install", percent: installProgressPercent, status } +} + +const parseSigningProgress = (message: string): ProgressUpdate | null => { + const lower = message.toLowerCase() + if (lower.includes("sign: preparing ipa")) { + return { source: "sign", percent: 8, status: "preparing ipa" } + } + if (lower.includes("signing stage: refreshing team")) { + return { source: "sign", percent: 14, status: "refreshing team" } + } + if (lower.includes("signing stage: refreshed team")) { + return { source: "sign", percent: 22, status: "team ready" } + } + if (lower.includes("signing stage: using team")) { + return { source: "sign", percent: 28, status: "using team" } + } + if (lower.includes("signing stage: creating development certificate")) { + return { source: "sign", percent: 36, status: "creating certificate" } + } + if (lower.includes("signing stage: using cached certificate")) { + return { source: "sign", percent: 40, status: "using certificate" } + } + if (lower.includes("signing stage: certificate ready")) { + return { source: "sign", percent: 48, status: "certificate ready" } + } + if (lower.includes("signing stage: registering device")) { + return { source: "sign", percent: 56, status: "registering device" } + } + if ( + lower.includes("signing stage: device already registered") || + lower.includes("signing stage: device registered") || + lower.includes("signing stage: device confirmed") + ) { + return { source: "sign", percent: 62, status: "device ready" } + } + if (lower.includes("signing stage: creating app id") || lower.includes("signing stage: reuse app id")) { + return { source: "sign", percent: 72, status: "app id ready" } + } + if (lower.includes("signing stage: fetching provisioning profile")) { + return { source: "sign", percent: 82, status: "fetching profile" } + } + if (lower.includes("signing stage: resigning ipa")) { + return { source: "sign", percent: 90, status: "resigning ipa" } + } + if (lower.includes("signing stage: complete") || lower.includes("sign: done ->")) { + return { source: "sign", percent: 100, status: "complete" } + } + return null +} + +const parseProgressFromLog = (message: string): ProgressUpdate | null => { + return parseInstallProgress(message) ?? parseSigningProgress(message) +} + +const applyProgressUpdate = (update: ProgressUpdate): void => { + if (busyInstall && update.source === "sign") { + const mapped = installProgressIncludesSigning ? Math.round(update.percent * 0.55) : update.percent + setInstallProgress(mapped, `signing: ${update.status}`) + return + } + if (busyInstall && update.source === "install") { + const mapped = installProgressIncludesSigning + ? 55 + Math.round(update.percent * 0.45) + : update.percent + setInstallProgress(mapped, `installing: ${update.status}`) + return + } + if (update.source === "sign") { + setInstallProgress(update.percent, `signing: ${update.status}`) + return + } + setInstallProgress(update.percent, `installing: ${update.status}`) +} + +const sanitizeDemoLogText = (text: string): string => { + return text + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[email]") + .replace(/\budid=([A-Za-z0-9-]+)/gi, "udid=[hidden]") + .replace(/\b([A-Fa-f0-9]{24,64})\b/g, "[id]") + .replace(/(ipa selected:\s*).+/i, "$1[file]") + .replace(/(ipa dropped:\s*).+/i, "$1[file]") + .replace(/(sign: done ->\s*).+/i, "$1[file]") + .replace(/(loaded local pair record for\s+).+/i, "$1[udid]") + .replace(/(registering device\s+)[^\s]+(\s+as\s+).+/i, "$1[udid]$2[device]") + .replace(/(device (already )?registered \()[^)]+(\))/i, "$1[udid]$3") + .replace(/(team=)[^ ]+\s+\([^)]+\)/i, "$1[hidden]") + .replace(/(using team\s+)[^ ]+\s+\([^)]+\)/i, "$1[hidden]") +} + +const applyDemoMode = (): void => { + document.body.classList.toggle("demo-mode", demoModeEnabled) + demoModeToggle.checked = demoModeEnabled + appleIdInput.type = demoModeEnabled ? "password" : "email" + appleIdInput.autocomplete = demoModeEnabled ? "off" : "username" + appleIdInput.placeholder = demoModeEnabled ? "hidden in demo mode" : "your apple id" + applePasswordInput.placeholder = demoModeEnabled + ? "hidden in demo mode" + : "app-specific password" + if (demoModeEnabled) { + for (let index = 0; index < logLines.length; index += 1) { + logLines[index] = sanitizeDemoLogText(logLines[index]) + } + } + renderLogView() + refreshInstallProgressUi() +} + +const loadAccountSummaryText = (): string => { + if (loginContext) { + return `${loginContext.appleId} / ${loginContext.team.identifier}` + } + const stored = loadStoredAccountSummary() + if (!stored) { + return "-" + } + return `${stored.appleId} / ${stored.teamId}` +} + +pairDeviceButton.addEventListener("click", async () => { + try { + await pairDeviceFlow() + } catch (error) { + addLog(`pair failed: ${formatError(error)}`) + refreshUi() + } +}) + +loginSignButton.addEventListener("click", async () => { + try { + await loginAndSignFlow() + } catch (error) { + addLog(`login/sign failed: ${formatError(error)}`) + refreshUi() + } +}) + +installButton.addEventListener("click", async () => { + try { + await installFlow() + } catch (error) { + addLog(`install failed: ${formatError(error)}`) + refreshUi() + } +}) + appleIdInput.addEventListener("change", () => { saveText(APPLE_ID_STORAGE_KEY, appleIdInput.value.trim()) + refreshUi() }) -signBundleIdInput.addEventListener("change", () => { - saveText(SIGN_BUNDLE_ID_STORAGE_KEY, signBundleIdInput.value.trim()) +demoModeToggle.addEventListener("change", () => { + demoModeEnabled = demoModeToggle.checked + saveText(DEMO_MODE_STORAGE_KEY, demoModeEnabled ? "1" : "0") + applyDemoMode() + addLog(demoModeEnabled ? "demo mode enabled" : "demo mode disabled") + refreshUi() }) -signDisplayNameInput.addEventListener("change", () => { - saveText(SIGN_DISPLAY_NAME_STORAGE_KEY, signDisplayNameInput.value.trim()) +ipaFileInput.addEventListener("change", () => { + selectedIpaFile = ipaFileInput.files && ipaFileInput.files.length > 0 ? ipaFileInput.files[0] : null + clearPreparedSigned() + addLog(selectedIpaFile ? `ipa selected: ${selectedIpaFile.name}` : "ipa selection cleared") + refreshUi() }) -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) => { +dropArea.addEventListener("dragenter", (event) => { event.preventDefault() - ipaDropZone.classList.add("dragover") + dropArea.classList.add("dragover") }) -ipaDropZone.addEventListener("dragover", (event) => { +dropArea.addEventListener("dragover", (event) => { event.preventDefault() - ipaDropZone.classList.add("dragover") + dropArea.classList.add("dragover") }) -ipaDropZone.addEventListener("dragleave", () => { - ipaDropZone.classList.remove("dragover") +dropArea.addEventListener("dragleave", () => { + dropArea.classList.remove("dragover") }) -ipaDropZone.addEventListener("drop", async (event) => { +dropArea.addEventListener("drop", (event) => { event.preventDefault() - ipaDropZone.classList.remove("dragover") - + dropArea.classList.remove("dragover") const file = event.dataTransfer?.files?.[0] ?? null if (!file) { - addLog("Drop ignored: no file.") + addLog("drop ignored: no file") return } - await handleSelectedIpaFile(file) + selectedIpaFile = file + clearPreparedSigned() + addLog(`ipa dropped: ${file.name}`) + refreshUi() }) window.addEventListener("beforeunload", () => { void directClient?.close() }) -addLog("Demo ready.") -refreshState() +applyDemoMode() +addLog("ready") +refreshUi() + +function buildPreparedSourceKey(file: File, udid: string, teamId: string): string { + return `${file.name}:${file.size}:${file.lastModified}:${udid}:${teamId}` +} function mustGetElement(id: string): HTMLElement { const element = document.getElementById(id) @@ -618,6 +830,14 @@ function mustGetInput(id: string): HTMLInputElement { return element } +function mustGetButton(id: string): HTMLButtonElement { + const element = document.getElementById(id) + if (!element || !(element instanceof HTMLButtonElement)) { + throw new Error(`Button #${id} not found`) + } + return element +} + function loadText(key: string): string | null { return window.localStorage.getItem(key) } @@ -750,6 +970,35 @@ function shortToken(value: string): string { return `${text.slice(0, 6)}...${text.slice(-4)}` } +function persistAccountSummary(context: AppleDeveloperContext): void { + const payload: StoredAccountSummary = { + appleId: context.appleId, + teamId: context.team.identifier, + teamName: context.team.name, + updatedAtIso: new Date().toISOString(), + } + saveText(APPLE_ACCOUNT_SUMMARY_STORAGE_KEY, JSON.stringify(payload)) +} + +function loadStoredAccountSummary(): StoredAccountSummary | null { + const raw = loadText(APPLE_ACCOUNT_SUMMARY_STORAGE_KEY) + if (!raw) { + return null + } + try { + const parsed = JSON.parse(raw) as StoredAccountSummary + if (!parsed || typeof parsed !== "object") { + return null + } + if (!parsed.appleId || !parsed.teamId || !parsed.teamName) { + return null + } + return parsed + } catch { + return null + } +} + function formatError(error: unknown): string { if (error instanceof Error) { return error.message diff --git a/frontend/src/style.css b/frontend/src/style.css index 17d015b..af12d44 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1,196 +1,290 @@ -@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; } +:root { + color-scheme: light; +} + 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)); + background: #f4f6fb; + color: #131722; + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; } #app { - max-width: 1160px; + padding: 32px 16px; +} + +.page { + width: min(960px, 100%); 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; + border: 1px solid #d6dbe5; + border-radius: 16px; + background: #fff; + padding: 26px 22px 22px; + box-shadow: 0 12px 28px rgba(22, 31, 53, 0.08); } -.eyebrow { - margin: 0; - color: var(--accent-2); - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - font-size: 12px; +.hero { + position: relative; + margin-bottom: 18px; } h1 { - margin: 10px 0 4px; - font-size: clamp(1.8rem, 5vw, 2.8rem); - line-height: 1.05; -} - -.subline { margin: 0; - color: var(--ink-soft); + text-align: center; + font-size: 30px; + font-weight: 700; + letter-spacing: 0.02em; } -.drop-zone { - margin-top: 18px; - border: 2px dashed rgba(12, 127, 122, 0.45); +.demo-toggle { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #495368; + user-select: none; +} + +.demo-toggle input { + width: 14px; + height: 14px; + margin: 0; +} + +.drop-area { + width: min(360px, 100%); + height: 186px; + margin: 0 auto 22px; + border: 1px dashed #9ca8bc; border-radius: 14px; - padding: 16px 14px; + background: #f8faff; + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; + justify-content: center; + cursor: pointer; + user-select: none; + transition: 0.18s ease; +} + +.drop-area.dragover { + border-color: #4f7cff; + background: #eef3ff; +} + +.drop-area input { + display: none; +} + +#drop-label { + max-width: calc(100% - 24px); + font-size: 18px; + color: #1f2839; text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.drop-tip { + font-size: 12px; + color: #6c7688; +} + +.row { + display: grid; + gap: 10px; + align-items: center; + margin-top: 10px; +} + +.login-row { + grid-template-columns: auto minmax(170px, 1fr) auto minmax(170px, 1fr) auto; +} + +.action-row { + grid-template-columns: auto auto minmax(210px, 1fr) auto; +} + +.k { 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; + color: #4f5a6d; + white-space: nowrap; } -.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 { +input { width: 100%; + height: 36px; + border: 1px solid #cfd6e2; border-radius: 10px; - border: 1px solid var(--border); - background: rgba(255, 255, 255, 0.7); - color: var(--ink); - padding: 10px 12px; + padding: 0 11px; font-size: 14px; - font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, monospace; + background: #fdfdff; + color: #111827; } -.request-row input:focus { - outline: 2px solid rgba(12, 127, 122, 0.24); - border-color: rgba(12, 127, 122, 0.45); +input:focus { + outline: 2px solid #dce5ff; + border-color: #7090f8; + background: #fff; } -.state-grid { - margin-top: 20px; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 10px; +button { + height: 36px; + border: 1px solid #111827; + border-radius: 10px; + background: #111827; + color: #fff; + padding: 0 13px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: 0.15s ease; } -.state-item { - border: 1px solid var(--border); - border-radius: 12px; - padding: 10px 12px; - display: grid; - gap: 6px; +button:hover { + background: #1f2a3d; } -.label { - font-size: 11px; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--ink-soft); +#pair-device-btn { + border-color: #cfd6e2; + background: #fff; + color: #182033; } -.value { - font-size: 16px; - font-weight: 700; +#pair-device-btn:hover { + background: #f4f7ff; } -.log-panel { - padding: 20px; - display: grid; - grid-template-rows: auto 1fr; - min-height: 430px; +button:disabled { + opacity: 0.46; + cursor: not-allowed; } -.log-head { +.udid { + display: block; + min-height: 36px; + line-height: 36px; + border: 1px solid #cfd6e2; + border-radius: 10px; + padding: 0 10px; + background: #f8faff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + color: #1f2a3d; +} + +.status-line { + margin-top: 10px; + border: 1px solid #d8deea; + border-radius: 10px; + padding: 8px 10px; + font-size: 12px; + background: #f9fbff; + color: #495368; + overflow-wrap: anywhere; +} + +.progress-wrap { + margin-top: 10px; + border: 1px solid #d8deea; + border-radius: 10px; + background: #f9fbff; + padding: 8px 10px 10px; +} + +.progress-head { display: flex; justify-content: space-between; - align-items: center; + gap: 10px; + font-size: 12px; + color: #495368; + margin-bottom: 8px; } -h2 { - margin: 0; - font-size: 18px; +.progress-track { + height: 10px; + border-radius: 999px; + background: #e8edf9; + overflow: hidden; +} + +.progress-bar { + width: 0; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #7aa2ff 0%, #4f7cff 100%); + transition: width 0.2s ease; } .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; + height: 240px; + border: 1px solid #d7dce9; + border-radius: 12px; + background: #fbfcff; + color: #1f2a3d; + padding: 10px; + overflow: auto; font-size: 12px; line-height: 1.45; - overflow: auto; white-space: pre-wrap; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; } -@media (max-width: 860px) { - .shell { - grid-template-columns: 1fr; +body.demo-mode .status-line, +body.demo-mode .progress-wrap, +body.demo-mode .log, +body.demo-mode .udid { + background: #f2f4f9; +} + +@media (max-width: 900px) { + #app { + padding: 18px 12px; } - .request-row { - grid-template-columns: 1fr; + .panel { + padding: 20px 14px 14px; + border-radius: 12px; } - .state-grid { + .hero { + display: grid; + gap: 8px; + justify-items: center; + } + + .demo-toggle { + position: static; + transform: none; + } + + .drop-area { + width: 100%; + } + + .login-row, + .action-row { grid-template-columns: 1fr; + gap: 8px; } }