refactor: migrate frontend to React + Tailwind, add Docker + tests

Replace the vanilla-TS innerHTML frontend with a type-checked React
component tree (React 19 + Tailwind v4 + Vite).

Frontend:
- 14 components: Header, Stepper, LoginPage, LoginModal, SignPage,
  DropZone, DevicePicker, ProgressCard, SavedAccountsList, TrustModal,
  TwoFactorModal, Button, Field, Chip, Modal
- lib/ extracts: storage (10 localStorage keys preserved), pair-record,
  account-session, log-parser, ids, use-log hook
- flows/ encapsulate async pair/login/sign/install with dependency injection
- Accounts page as main view with Add Account modal
- Fullscreen progress overlay during sign/install
- Account selector + device picker on Sign page
- Security notice in login modal (server trust warning)
- All addLog calls mirrored to console.log for devtools debugging

Build:
- bun run dev: submodule init + install + wasm dist + vite + wrangler
- bun run setup: one-shot project bootstrap
- Docker: multi-stage bun build → nginx on :3000
- build:wasm:dist copies pre-built src→dist (no Rust/Emscripten needed)
- jszip/node-forge/fflate pre-bundled for CJS→ESM conversion

Tests:
- 163 vitest tests (happy-dom): all lib, components, App integration,
  WASM dist artifact checks, libcurl Apple connectivity, anisette init
  error handling

Cleanup:
- Delete yarn.lock (bun.lock canonical), expand .gitignore
- Remove README.zh.md, rewrite README.md + AGENTS.md
- Update libcurl.js submodule to f65d440 (CI build artifacts)
This commit is contained in:
Lakr
2026-04-13 17:02:45 +08:00
parent 3ed8ddc5dc
commit afec333aa0
79 changed files with 6543 additions and 6392 deletions

View File

@@ -0,0 +1,15 @@
import { installIpaViaInstProxy, sanitizeIpaFileName, type DirectUsbMuxClient } from 'webmuxd';
export interface InstallRequest {
client: DirectUsbMuxClient;
signedFile: File;
log: (msg: string) => void;
}
export async function installFlow(req: InstallRequest): Promise<void> {
req.log('install: uploading and installing...');
const bytes = new Uint8Array(await req.signedFile.arrayBuffer());
const safeName = sanitizeIpaFileName(req.signedFile.name);
await installIpaViaInstProxy(req.client, bytes, safeName, req.log);
req.log('install: complete');
}

View File

@@ -0,0 +1,77 @@
import type { AnisetteData } from '../anisette-service';
import type { AppleDeveloperContext } from '../apple-signing';
import { shortToken } from '../lib/ids';
type AnisetteService = typeof import('../anisette-service');
type AppleSigningModule = typeof import('../apple-signing');
let anisetteServicePromise: Promise<AnisetteService> | null = null;
let appleSigningModulePromise: Promise<AppleSigningModule> | null = null;
export async function loadAnisetteService(): Promise<AnisetteService> {
if (!anisetteServicePromise) {
anisetteServicePromise = import('../anisette-service');
}
return await anisetteServicePromise;
}
export async function loadAppleSigningModule(): Promise<AppleSigningModule> {
if (!appleSigningModulePromise) {
appleSigningModulePromise = import('../apple-signing');
}
return await appleSigningModulePromise;
}
export interface EnsureAnisetteResult {
anisetteData: AnisetteData;
provisioned: boolean;
}
export async function ensureAnisetteData(
existing: AnisetteData | null,
log: (msg: string) => void,
): Promise<EnsureAnisetteResult> {
if (existing) {
return { anisetteData: existing, provisioned: true };
}
const anisetteService = await loadAnisetteService();
const anisette = await anisetteService.initAnisette();
if (anisette.isProvisioned) {
log('login: anisette already provisioned');
} else {
log('login: preparing anisette environment...');
await anisetteService.provisionAnisette();
log('login: anisette provisioned');
}
const anisetteData = await anisetteService.getAnisetteData();
log(`login: anisette ready (${shortToken(anisetteData.machineID)})`);
return { anisetteData, provisioned: true };
}
export async function checkAnisetteProvisioned(): Promise<boolean> {
const anisetteService = await loadAnisetteService();
const anisette = await anisetteService.initAnisette();
return anisette.isProvisioned;
}
export interface LoginRequest {
appleId: string;
password: string;
anisetteData: AnisetteData;
log: (msg: string) => void;
onTwoFactorRequired: (submit: (code: string) => void) => void;
}
export async function loginAccount(req: LoginRequest): Promise<AppleDeveloperContext> {
const appleSigning = await loadAppleSigningModule();
req.log('login: authenticating Apple account...');
const context = await appleSigning.loginAppleDeveloperAccount({
anisetteData: req.anisetteData,
credentials: { appleId: req.appleId, password: req.password },
onLog: req.log,
onTwoFactorRequired: req.onTwoFactorRequired,
});
return await appleSigning.refreshAppleDeveloperContext(context, req.log);
}

View File

@@ -0,0 +1,99 @@
import { DirectUsbMuxClient, LOCKDOWN_PORT, WebUsbTransport, createOpenSslWasmTlsFactory } from 'webmuxd';
import {
createPairRecord,
getOrCreateHostId,
getOrCreateSystemBuid,
loadPairRecordForUdid,
savePairRecordForUdid,
} from '../lib/pair-record';
import { HOST_ID_STORAGE_KEY, SYSTEM_BUID_STORAGE_KEY, saveText } from '../lib/storage';
export interface PairedDeviceInfo {
udid: string;
name: string | null;
}
export interface PairContext {
log: (message: string) => void;
clientRef: { current: DirectUsbMuxClient | null };
onStateChange: () => void;
onTrustPending: () => void;
}
export function isPairingDialogPendingError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes('PairingDialogResponsePending');
}
export async function ensureClientSelected(ctx: PairContext): Promise<DirectUsbMuxClient> {
if (ctx.clientRef.current) return ctx.clientRef.current;
const transport = await WebUsbTransport.requestAppleDevice();
const client = new DirectUsbMuxClient(transport, {
log: ctx.log,
onStateChange: ctx.onStateChange,
lockdownLabel: 'webmuxd.frontend',
tlsFactory: createOpenSslWasmTlsFactory(),
pairRecordFactory: {
createPairRecord: async (request) => {
return await createPairRecord(request.devicePublicKey, request.hostId, request.systemBuid);
},
},
});
ctx.clientRef.current = client;
ctx.log('device selected from browser popup');
ctx.onStateChange();
return client;
}
export async function pairDeviceFlow(ctx: PairContext): Promise<PairedDeviceInfo> {
const client = await ensureClientSelected(ctx);
if (!client.isHandshakeComplete) {
ctx.log('pair: opening mux handshake...');
await client.openAndHandshake();
}
if (!client.isLockdownConnected) {
ctx.log('pair: connecting lockdownd...');
await client.connectLockdown(LOCKDOWN_PORT);
}
const udid = await client.getOrFetchDeviceUdid();
const name = await client.getOrFetchDeviceName();
let hostId = getOrCreateHostId();
let systemBuid = getOrCreateSystemBuid();
const storedPair = loadPairRecordForUdid(udid);
if (storedPair && !client.isPaired) {
client.loadPairRecord(storedPair);
hostId = storedPair.hostId;
systemBuid = storedPair.systemBuid;
saveText(HOST_ID_STORAGE_KEY, hostId);
saveText(SYSTEM_BUID_STORAGE_KEY, systemBuid);
ctx.log(`pair: loaded local pair record for ${udid}`);
}
if (!client.isPaired) {
ctx.log('pair: creating pair record...');
try {
const pairResult = await client.pairDevice(hostId, systemBuid);
savePairRecordForUdid(udid, pairResult);
ctx.log('pair: success');
} catch (error) {
if (isPairingDialogPendingError(error)) {
ctx.onTrustPending();
}
throw error;
}
}
if (!client.isSessionStarted) {
const session = await client.startSession(hostId, systemBuid);
ctx.log(`pair: session ready, ssl=${String(session.enableSessionSsl)}`);
}
ctx.log(`pair: udid=${udid}${name ? ` (${name})` : ''}`);
return { udid, name };
}

View File

@@ -0,0 +1,43 @@
import type { AnisetteData } from '../anisette-service';
import type { AppleDeveloperContext } from '../apple-signing';
import { loadAppleSigningModule } from './login';
export interface SignIpaRequest {
ipaFile: File;
context: AppleDeveloperContext;
anisetteData: AnisetteData;
deviceUdid: string;
deviceName?: string;
log: (msg: string) => void;
}
export interface SignIpaResult {
signedFile: File;
context: AppleDeveloperContext;
}
export async function signIpaFlow(req: SignIpaRequest): Promise<SignIpaResult> {
const appleSigning = await loadAppleSigningModule();
const contextWithAnisette: AppleDeveloperContext = {
...req.context,
session: {
...req.context.session,
anisetteData: req.anisetteData,
},
};
const refreshed = await appleSigning.refreshAppleDeveloperContext(contextWithAnisette, req.log);
req.log('sign: preparing ipa...');
const result = await appleSigning.signIpaWithAppleContext({
ipaFile: req.ipaFile,
context: refreshed,
deviceUdid: req.deviceUdid,
deviceName: req.deviceName,
onLog: req.log,
});
req.log(`sign: done -> ${result.signedFile.name}`);
return { signedFile: result.signedFile, context: refreshed };
}