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

@@ -1,12 +1,18 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AltStore Web</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)" />
<meta
name="description"
content="Browser-based AltStore-style developer signing — pair, sign, install IPAs locally."
/>
<title>AltStore Web — Sign &amp; Install</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -7,7 +7,10 @@
"dev": "vite",
"build": "tsc && vite build",
"serve": "vite preview",
"preview": "vite preview"
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@lbr77/anisette-js": "0.1.3",
@@ -17,13 +20,23 @@
"jszip": "^3.10.1",
"libcurl.js": "workspace:*",
"node-forge": "^1.3.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"webmuxd": "workspace:*"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.4.0",
"happy-dom": "^15.11.7",
"tailwindcss": "^4.2.1",
"typescript": "~5.9.3",
"vite": "^7.3.1"
"vite": "^7.3.1",
"vitest": "^2.1.8"
},
"packageManager": "bun@1.3.11"
}

File diff suppressed because one or more lines are too long

231
frontend/src/App.test.tsx Normal file
View File

@@ -0,0 +1,231 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AnisetteData } from './anisette-service';
import type { AppleDeveloperContext } from './apple-signing';
import type { PairedDeviceInfo } from './flows/pair';
// ---- mock every flow module ----
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pairDeviceFlowMock = vi.fn<(ctx: any) => Promise<PairedDeviceInfo>>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ensureClientSelectedMock = vi.fn<(ctx: any) => Promise<any>>();
const isPairingDialogPendingErrorMock = vi.fn<(error: unknown) => boolean>();
vi.mock('./flows/pair', () => ({
pairDeviceFlow: (ctx: unknown) => pairDeviceFlowMock(ctx),
ensureClientSelected: (ctx: unknown) => ensureClientSelectedMock(ctx),
isPairingDialogPendingError: (err: unknown) => isPairingDialogPendingErrorMock(err),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ensureAnisetteMock = vi.fn<(existing: any) => Promise<{ anisetteData: AnisetteData; provisioned: boolean }>>();
const checkProvisionedMock = vi.fn<() => Promise<boolean>>();
const loginAccountMock =
vi.fn<
(req: {
appleId: string;
password: string;
onTwoFactorRequired: (submit: (code: string) => void) => void;
log: (msg: string) => void;
}) => Promise<AppleDeveloperContext>
>();
vi.mock('./flows/login', () => ({
ensureAnisetteData: (existing: AnisetteData | null) => ensureAnisetteMock(existing),
checkAnisetteProvisioned: () => checkProvisionedMock(),
loginAccount: (req: Parameters<typeof loginAccountMock>[0]) => loginAccountMock(req),
loadAnisetteService: async () => ({}),
loadAppleSigningModule: async () => ({}),
}));
const signIpaFlowMock = vi.fn();
vi.mock('./flows/sign', () => ({
signIpaFlow: (req: unknown) => signIpaFlowMock(req),
}));
const installFlowMock = vi.fn();
vi.mock('./flows/install', () => ({
installFlow: (req: unknown) => installFlowMock(req),
}));
vi.mock('webmuxd', () => ({}));
// ---- fixtures ----
const fakeAnisette: AnisetteData = {
machineID: 'MID',
oneTimePassword: 'OTP',
localUserID: 'LUID',
routingInfo: 17106176,
deviceUniqueIdentifier: 'DUI',
deviceDescription: 'desc',
deviceSerialNumber: '0',
date: new Date('2024-01-01T00:00:00.000Z'),
locale: 'en_US',
timeZone: 'UTC',
};
const fakeContext: AppleDeveloperContext = {
appleId: 'user@example.com',
session: {
anisetteData: fakeAnisette,
dsid: 'dsid-1',
authToken: 'auth-1',
},
team: { identifier: 'TEAMX', name: 'Team X' } as AppleDeveloperContext['team'],
certificates: [],
devices: [],
};
import { App } from './App';
beforeEach(() => {
window.localStorage.clear();
window.location.hash = '';
checkProvisionedMock.mockResolvedValue(false);
ensureAnisetteMock.mockResolvedValue({ anisetteData: fakeAnisette, provisioned: true });
loginAccountMock.mockResolvedValue(fakeContext);
isPairingDialogPendingErrorMock.mockReturnValue(false);
pairDeviceFlowMock.mockResolvedValue({ udid: 'UDID-TEST', name: 'iPhone' });
ensureClientSelectedMock.mockResolvedValue({ isSessionStarted: true, close: async () => {} });
signIpaFlowMock.mockImplementation(async (req: { ipaFile: File; context: AppleDeveloperContext }) => ({
signedFile: new File([new Uint8Array(8)], `signed-${req.ipaFile.name}`),
context: req.context,
}));
installFlowMock.mockResolvedValue(undefined);
});
describe('App — page + nav', () => {
it('mounts on the accounts page and defaults the URL hash', async () => {
render(<App />);
expect(screen.getByRole('heading', { name: 'Accounts' })).toBeInTheDocument();
expect(window.location.hash).toBe('#/login');
await waitFor(() => expect(checkProvisionedMock).toHaveBeenCalled());
});
it('navigates between accounts and sign pages via the header', async () => {
render(<App />);
await userEvent.click(screen.getByRole('button', { name: /Sign & Install/ }));
expect(window.location.hash).toBe('#/sign');
await userEvent.click(screen.getByRole('button', { name: 'Account' }));
expect(window.location.hash).toBe('#/login');
});
});
describe('App — login modal', () => {
it('opens login modal on Add Account, then logs in and navigates to sign', async () => {
render(<App />);
// Click Add Account to open modal
await userEvent.click(screen.getByRole('button', { name: 'Add Account' }));
expect(screen.getByText('Add Account', { selector: 'h2' })).toBeInTheDocument();
// Fill form
await userEvent.type(screen.getByLabelText('Apple ID'), 'user@example.com');
await userEvent.type(screen.getByLabelText('Password'), 'secret');
// Sign In
await userEvent.click(screen.getByRole('button', { name: 'Sign In' }));
await waitFor(() => expect(loginAccountMock).toHaveBeenCalled());
// Navigates to sign page after login
await waitFor(() => expect(screen.getByRole('heading', { name: /Sign & Install/ })).toBeInTheDocument());
});
it('shows 2FA modal when login flow requests a code', async () => {
let resolveLogin: (ctx: AppleDeveloperContext) => void = () => {};
loginAccountMock.mockImplementationOnce(async (req) => {
req.onTwoFactorRequired((code) => {
if (code === '123456') resolveLogin(fakeContext);
});
return await new Promise<AppleDeveloperContext>((resolve) => {
resolveLogin = resolve;
});
});
render(<App />);
await userEvent.click(screen.getByRole('button', { name: 'Add Account' }));
await userEvent.type(screen.getByLabelText('Apple ID'), 'u@e.com');
await userEvent.type(screen.getByLabelText('Password'), 'pw');
await userEvent.click(screen.getByRole('button', { name: 'Sign In' }));
const code = await screen.findByLabelText('Verification Code');
await userEvent.type(code, '123456');
await userEvent.click(screen.getByRole('button', { name: 'Verify' }));
await waitFor(() => expect(screen.getByRole('heading', { name: /Sign & Install/ })).toBeInTheDocument());
});
});
describe('App — saved accounts rehydration', () => {
it('loads stored session and shows Active in accounts list', async () => {
window.localStorage.setItem(
'webmuxd:apple-account-summary',
JSON.stringify({
appleId: 'stored@e.com',
teamId: 'T1',
teamName: 'Stored Team',
updatedAtIso: '2024-01-01T00:00:00.000Z',
}),
);
window.localStorage.setItem(
'webmuxd:apple-account-list',
JSON.stringify([
{
appleId: 'stored@e.com',
teamId: 'T1',
teamName: 'Stored Team',
updatedAtIso: '2024-01-01T00:00:00.000Z',
},
]),
);
window.localStorage.setItem(
'webmuxd:apple-account-session-map',
JSON.stringify({
'stored@e.com::T1': {
appleId: 'stored@e.com',
teamId: 'T1',
teamName: 'Stored Team',
dsid: 'd',
authToken: 't',
anisetteData: {
machineID: 'MID',
oneTimePassword: 'OTP',
localUserID: 'LUID',
routingInfo: 17106176,
deviceUniqueIdentifier: 'DUI',
deviceDescription: 'desc',
deviceSerialNumber: '0',
dateIso: '2024-01-01T00:00:00.000Z',
locale: 'en_US',
timeZone: 'UTC',
},
updatedAtIso: '2024-01-01T00:00:00.000Z',
},
}),
);
render(<App />);
// Active label appears for the restored session
expect(await screen.findByText('Active')).toBeInTheDocument();
expect(screen.getByText('stored@e.com')).toBeInTheDocument();
});
});
describe('App — delete account', () => {
it('removes an account from the list when delete is clicked', async () => {
window.localStorage.setItem(
'webmuxd:apple-account-list',
JSON.stringify([{ appleId: 'a@e.com', teamId: 'T1', teamName: 'A', updatedAtIso: '2024-01-01' }]),
);
render(<App />);
const deleteBtn = await screen.findByTitle('Remove account');
await userEvent.click(deleteBtn);
expect(screen.getByText('No accounts yet.')).toBeInTheDocument();
});
});

612
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,612 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { DirectUsbMuxClient } from 'webmuxd';
import type { AnisetteData } from './anisette-service';
import type { AppleDeveloperContext } from './apple-signing';
import { Header, type AppPage } from './components/Header';
import { LoginPage } from './components/LoginPage';
import { LoginModal } from './components/LoginModal';
import { SignPage } from './components/SignPage';
import { TrustModal } from './components/TrustModal';
import { TwoFactorModal } from './components/TwoFactorModal';
import { ProgressCard } from './components/ProgressCard';
import { ensureClientSelected, isPairingDialogPendingError, pairDeviceFlow, type PairedDeviceInfo } from './flows/pair';
import { checkAnisetteProvisioned, ensureAnisetteData, loginAccount } from './flows/login';
import { signIpaFlow } from './flows/sign';
import { installFlow } from './flows/install';
import {
APPLE_ACCOUNT_LIST_STORAGE_KEY,
APPLE_ACCOUNT_SUMMARY_STORAGE_KEY,
APPLE_ID_STORAGE_KEY,
SELECTED_DEVICE_UDID_STORAGE_KEY,
loadText,
saveText,
writeJson,
} from './lib/storage';
import { accountKey, buildPreparedSourceKey, formatError } from './lib/ids';
import { listKnownDeviceUdids } from './lib/pair-record';
import {
loadStoredAccountList,
loadStoredAccountSummary,
persistAccountSession,
persistAccountSummary,
removeStoredAccountSession,
restorePersistedAccountContexts,
setStoredAccountSummary,
type StoredAccountSummary,
} from './lib/account-session';
import { parseProgressFromLog } from './lib/log-parser';
import { useLog } from './lib/use-log';
const LOGIN_PAGE_HASH = '#/login';
const SIGN_PAGE_HASH = '#/sign';
type BusyState = {
pair: boolean;
loginSign: boolean;
sign: boolean;
install: boolean;
};
const idleBusy: BusyState = { pair: false, loginSign: false, sign: false, install: false };
function resolvePageFromHash(hash: string): AppPage {
return hash === SIGN_PAGE_HASH ? 'sign' : 'login';
}
function pageToHash(page: AppPage): string {
return page === 'sign' ? SIGN_PAGE_HASH : LOGIN_PAGE_HASH;
}
export function App() {
const { lines: logLines, addLog: rawAddLog } = useLog();
// Form (for login modal)
const [appleId, setAppleId] = useState<string>(() => loadText(APPLE_ID_STORAGE_KEY) ?? '');
const [password, setPassword] = useState<string>('');
// Navigation
const [currentPage, setCurrentPage] = useState<AppPage>(() => resolvePageFromHash(window.location.hash));
// Auth / session
const [loginContext, setLoginContext] = useState<AppleDeveloperContext | null>(null);
const [savedAccounts, setSavedAccounts] = useState<StoredAccountSummary[]>(() => loadStoredAccountList());
const accountContextMapRef = useRef<Map<string, AppleDeveloperContext>>(new Map());
// Login modal
const [loginModalOpen, setLoginModalOpen] = useState(false);
// Device
const [pairedDeviceInfo, setPairedDeviceInfo] = useState<PairedDeviceInfo | null>(null);
const [selectedTargetUdid, setSelectedTargetUdid] = useState<string>(
() => loadText(SELECTED_DEVICE_UDID_STORAGE_KEY) ?? '',
);
const [pairRecordsVersion, setPairRecordsVersion] = useState<number>(0);
const directClientRef = useRef<DirectUsbMuxClient | null>(null);
// File + signing
const [selectedIpaFile, setSelectedIpaFile] = useState<File | null>(null);
const [prepared, setPrepared] = useState<{ file: File; sourceKey: string } | null>(null);
// Anisette
const [anisetteData, setAnisetteData] = useState<AnisetteData | null>(null);
const [anisetteProvisioned, setAnisetteProvisioned] = useState<boolean>(false);
// Busy + progress
const [busy, setBusy] = useState<BusyState>(idleBusy);
const busyRef = useRef<BusyState>(idleBusy);
busyRef.current = busy;
const [progress, setProgress] = useState<{ percent: number; status: string }>({
percent: 0,
status: 'idle',
});
const lastInstallPercentRef = useRef<number>(0);
// Modals
const [trustOpen, setTrustOpen] = useState<boolean>(false);
const [twoFactor, setTwoFactor] = useState<{
open: boolean;
submit: ((code: string) => void) | null;
}>({ open: false, submit: null });
// Derived
const isWebUsbSupported = useMemo(() => {
try {
return typeof navigator !== 'undefined' && 'usb' in navigator;
} catch {
return false;
}
}, []);
const knownUdids = useMemo(
() => listKnownDeviceUdids(pairedDeviceInfo?.udid ?? null),
[pairRecordsVersion, pairedDeviceInfo],
);
void anisetteProvisioned; // consumed by checkAnisetteProvisioned on mount
const activeAccountKey = loginContext ? accountKey(loginContext.appleId, loginContext.team.identifier) : null;
const cachedAccountKeys = useMemo(() => new Set(accountContextMapRef.current.keys()), [savedAccounts, loginContext]);
// ---- log + progress plumbing ----
const addLog = useCallback(
(message: string) => {
rawAddLog(message);
const update = parseProgressFromLog(message, lastInstallPercentRef.current);
if (!update) return;
if (update.source === 'install') {
lastInstallPercentRef.current = update.percent;
}
if (busyRef.current.sign && update.source === 'sign') {
setProgress({ percent: update.percent, status: `signing: ${update.status}` });
} else if (busyRef.current.install && update.source === 'install') {
setProgress({ percent: update.percent, status: `installing: ${update.status}` });
}
},
[rawAddLog],
);
// ---- navigation ----
const navigateToPage = useCallback((page: AppPage) => {
setCurrentPage(page);
const nextHash = pageToHash(page);
if (window.location.hash !== nextHash) {
window.location.hash = nextHash;
}
}, []);
useEffect(() => {
if (window.location.hash !== LOGIN_PAGE_HASH && window.location.hash !== SIGN_PAGE_HASH) {
window.location.hash = LOGIN_PAGE_HASH;
}
const onHashChange = () => {
setCurrentPage(resolvePageFromHash(window.location.hash));
};
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, []);
// ---- on mount: restore account contexts + active login ----
useEffect(() => {
const restored = restorePersistedAccountContexts();
accountContextMapRef.current = restored;
const summary = loadStoredAccountSummary();
if (summary) {
const active = restored.get(accountKey(summary.appleId, summary.teamId));
if (active) {
setLoginContext(active);
setAppleId(active.appleId);
addLog(`login: restored session ${active.appleId} / ${active.team.identifier}`);
}
}
addLog('ready');
void (async () => {
try {
const provisioned = await checkAnisetteProvisioned();
setAnisetteProvisioned(provisioned);
} catch (error) {
addLog(`anisette status check failed: ${formatError(error)}`);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ---- body scroll lock on modal open ----
useEffect(() => {
const anyOpen = trustOpen || twoFactor.open || loginModalOpen;
document.body.classList.toggle('modal-open', anyOpen);
return () => {
document.body.classList.remove('modal-open');
};
}, [trustOpen, twoFactor.open, loginModalOpen]);
// ---- close WebUSB on unload ----
useEffect(() => {
const handler = () => {
void directClientRef.current?.close();
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, []);
// ---- persist appleId ----
const handleAppleIdBlur = useCallback(() => {
saveText(APPLE_ID_STORAGE_KEY, appleId.trim());
}, [appleId]);
// ---- reset signed package when inputs change ----
const clearPrepared = useCallback(() => setPrepared(null), []);
const handleFileChange = useCallback(
(file: File | null) => {
setSelectedIpaFile(file);
clearPrepared();
if (file) {
addLog(`ipa selected: ${file.name}`);
} else {
addLog('ipa selection cleared');
}
},
[addLog, clearPrepared],
);
const handleSelectedUdidChange = useCallback(
(value: string) => {
setSelectedTargetUdid(value);
saveText(SELECTED_DEVICE_UDID_STORAGE_KEY, value);
clearPrepared();
if (value) {
addLog(`target udid selected: ${value}`);
} else {
addLog('target udid cleared');
}
},
[addLog, clearPrepared],
);
// ---- auto-reset invalid selected UDID ----
useEffect(() => {
if (selectedTargetUdid.length > 0 && !knownUdids.includes(selectedTargetUdid)) {
setSelectedTargetUdid('');
saveText(SELECTED_DEVICE_UDID_STORAGE_KEY, '');
}
}, [knownUdids, selectedTargetUdid]);
// ---- pair flow ----
const handlePair = useCallback(async () => {
if (busyRef.current.pair) return;
setBusy((prev) => ({ ...prev, pair: true }));
setTrustOpen(true);
addLog('pair: please continue on your device');
try {
const info = await pairDeviceFlow({
log: addLog,
clientRef: directClientRef,
onStateChange: () => {},
onTrustPending: () => {
addLog('pair: waiting for trust confirmation on device');
},
});
const changed = pairedDeviceInfo?.udid !== info.udid;
setPairedDeviceInfo(info);
if (changed) clearPrepared();
setSelectedTargetUdid(info.udid);
saveText(SELECTED_DEVICE_UDID_STORAGE_KEY, info.udid);
setPairRecordsVersion((v) => v + 1);
setTrustOpen(false);
} catch (error) {
if (isPairingDialogPendingError(error)) {
return;
}
setTrustOpen(false);
addLog(`pair failed: ${formatError(error)}`);
} finally {
setBusy((prev) => ({ ...prev, pair: false }));
}
}, [addLog, clearPrepared, pairedDeviceInfo]);
// ---- login flow ----
const handleLogin = useCallback(async () => {
if (busyRef.current.loginSign) return;
const trimmedAppleId = appleId.trim();
if (!trimmedAppleId || !password) {
addLog('login failed: please input email and password');
return;
}
setBusy((prev) => ({ ...prev, loginSign: true }));
try {
saveText(APPLE_ID_STORAGE_KEY, trimmedAppleId);
addLog('login: initializing anisette...');
const { anisetteData: nextAnisette } = await ensureAnisetteData(anisetteData, addLog);
setAnisetteData(nextAnisette);
setAnisetteProvisioned(true);
addLog('login: anisette ready, authenticating...');
const context = await loginAccount({
appleId: trimmedAppleId,
password,
anisetteData: nextAnisette,
log: addLog,
onTwoFactorRequired: (submit) => {
setTwoFactor({ open: true, submit });
addLog('login: 2FA required, opening verification dialog');
},
});
addLog(`login: authenticated as ${context.appleId} / ${context.team.identifier}`);
const key = accountKey(context.appleId, context.team.identifier);
accountContextMapRef.current.set(key, context);
persistAccountSummary(context);
persistAccountSession(context, nextAnisette);
addLog('login: session persisted');
setSavedAccounts(loadStoredAccountList());
setLoginContext(context);
setPassword('');
setLoginModalOpen(false);
clearPrepared();
addLog('login: done, navigating to sign page');
navigateToPage('sign');
} catch (error) {
addLog(`login failed: ${formatError(error)}`);
} finally {
setTwoFactor({ open: false, submit: null });
setBusy((prev) => ({ ...prev, loginSign: false }));
}
}, [addLog, anisetteData, appleId, clearPrepared, navigateToPage, password]);
const handleTwoFactorSubmit = useCallback(
(code: string) => {
const submit = twoFactor.submit;
setTwoFactor({ open: false, submit: null });
if (submit) submit(code);
},
[twoFactor.submit],
);
const handleTwoFactorCancel = useCallback(() => {
const submit = twoFactor.submit;
setTwoFactor({ open: false, submit: null });
if (submit) {
addLog('login: 2FA canceled');
submit('__CANCELLED__');
}
}, [addLog, twoFactor.submit]);
// ---- sign flow ----
const handleSign = useCallback(async () => {
if (busyRef.current.sign) return;
if (!selectedIpaFile || !loginContext) return;
const targetUdid = selectedTargetUdid.trim();
if (targetUdid.length === 0) return;
setBusy((prev) => ({ ...prev, sign: true }));
setProgress({ percent: 0, status: 'starting' });
lastInstallPercentRef.current = 0;
try {
const { anisetteData: nextAnisette } = await ensureAnisetteData(anisetteData, addLog);
setAnisetteData(nextAnisette);
setAnisetteProvisioned(true);
const result = await signIpaFlow({
ipaFile: selectedIpaFile,
context: loginContext,
anisetteData: nextAnisette,
deviceUdid: targetUdid,
deviceName: pairedDeviceInfo?.udid === targetUdid ? pairedDeviceInfo.name ?? undefined : undefined,
log: addLog,
});
const key = accountKey(result.context.appleId, result.context.team.identifier);
accountContextMapRef.current.set(key, result.context);
persistAccountSummary(result.context);
persistAccountSession(result.context, nextAnisette);
setSavedAccounts(loadStoredAccountList());
setLoginContext(result.context);
setPrepared({
file: result.signedFile,
sourceKey: buildPreparedSourceKey(selectedIpaFile, targetUdid),
});
setProgress({ percent: 100, status: 'complete' });
} catch (error) {
addLog(`sign failed: ${formatError(error)}`);
setProgress({ percent: 0, status: 'failed' });
} finally {
setBusy((prev) => ({ ...prev, sign: false }));
}
}, [addLog, anisetteData, loginContext, pairedDeviceInfo, selectedIpaFile, selectedTargetUdid]);
// ---- install flow ----
const handleInstall = useCallback(async () => {
if (busyRef.current.install) return;
if (!selectedIpaFile) return;
const targetUdid = selectedTargetUdid.trim();
if (targetUdid.length === 0) return;
setBusy((prev) => ({ ...prev, install: true }));
setProgress({ percent: 0, status: 'starting' });
lastInstallPercentRef.current = 0;
try {
const client = await ensureClientSelected({
log: addLog,
clientRef: directClientRef,
onStateChange: () => {},
onTrustPending: () => setTrustOpen(true),
});
if (!client.isSessionStarted) {
await handlePair();
}
if (pairedDeviceInfo?.udid !== targetUdid) {
throw new Error('connected device udid does not match selected target');
}
const currentSourceKey = buildPreparedSourceKey(selectedIpaFile, targetUdid);
if (!prepared || prepared.sourceKey !== currentSourceKey) {
throw new Error('please sign ipa first, then install');
}
await installFlow({ client, signedFile: prepared.file, log: addLog });
setProgress({ percent: 100, status: 'complete' });
} catch (error) {
addLog(`install failed: ${formatError(error)}`);
setProgress({ percent: 0, status: 'failed' });
} finally {
setBusy((prev) => ({ ...prev, install: false }));
}
}, [addLog, handlePair, pairedDeviceInfo, prepared, selectedIpaFile, selectedTargetUdid]);
// ---- switch account ----
const handleSwitchAccount = useCallback(
(summary: StoredAccountSummary) => {
const key = accountKey(summary.appleId, summary.teamId);
const cached = accountContextMapRef.current.get(key);
setAppleId(summary.appleId);
saveText(APPLE_ID_STORAGE_KEY, summary.appleId);
setStoredAccountSummary(summary);
setSavedAccounts(loadStoredAccountList());
clearPrepared();
if (cached) {
setLoginContext(cached);
addLog(`account switched: ${summary.appleId} / ${summary.teamId}`);
navigateToPage('sign');
return;
}
setLoginContext(null);
setLoginModalOpen(true);
addLog(`account selected: ${summary.appleId} / ${summary.teamId}, please sign in again`);
},
[addLog, clearPrepared, navigateToPage],
);
// ---- delete account ----
const handleDeleteAccount = useCallback(
(summary: StoredAccountSummary) => {
const key = accountKey(summary.appleId, summary.teamId);
// Remove from session map
removeStoredAccountSession(summary.appleId, summary.teamId);
accountContextMapRef.current.delete(key);
// Remove from account list
const list = loadStoredAccountList().filter(
(item) => !(item.appleId === summary.appleId && item.teamId === summary.teamId),
);
writeJson(APPLE_ACCOUNT_LIST_STORAGE_KEY, list);
// If this was the active account, clear it
const activeSummary = loadStoredAccountSummary();
if (activeSummary && activeSummary.appleId === summary.appleId && activeSummary.teamId === summary.teamId) {
if (list.length > 0) {
setStoredAccountSummary(list[0]);
} else {
// Clear the summary entirely
writeJson(APPLE_ACCOUNT_SUMMARY_STORAGE_KEY, null);
}
}
if (activeAccountKey === key) {
setLoginContext(null);
}
setSavedAccounts(list);
clearPrepared();
addLog(`account removed: ${summary.appleId} / ${summary.teamId}`);
},
[activeAccountKey, addLog, clearPrepared],
);
// ---- dismiss progress ----
const handleDismissProgress = useCallback(() => {
setProgress({ percent: 0, status: 'idle' });
}, []);
// ---- derived disabled flags ----
const progressBusy = busy.sign || busy.install;
const loginCanSubmit = !busy.loginSign && !twoFactor.open && appleId.trim().length > 0 && password.length > 0;
const currentSourceKey =
selectedIpaFile && selectedTargetUdid ? buildPreparedSourceKey(selectedIpaFile, selectedTargetUdid) : null;
const hasValidSignedPackage = !!prepared && !!currentSourceKey && prepared.sourceKey === currentSourceKey;
const pairDisabled = busy.pair || busy.sign || busy.install || !isWebUsbSupported;
const signDisabled =
busy.pair ||
busy.loginSign ||
busy.sign ||
busy.install ||
!selectedIpaFile ||
!loginContext ||
selectedTargetUdid.length === 0;
const installDisabled =
busy.pair ||
busy.loginSign ||
busy.sign ||
busy.install ||
!pairedDeviceInfo ||
pairedDeviceInfo.udid !== selectedTargetUdid ||
!hasValidSignedPackage;
return (
<main className="min-h-screen bg-bg">
<Header currentPage={currentPage} onNavigate={navigateToPage} />
<section className="mx-auto max-w-[760px] px-5 py-10 sm:px-7">
{currentPage === 'login' ? (
<LoginPage
loggedIn={!!loginContext}
savedAccounts={savedAccounts}
activeAccountKey={activeAccountKey}
cachedAccountKeys={cachedAccountKeys}
onSwitchAccount={handleSwitchAccount}
onDeleteAccount={handleDeleteAccount}
onAddAccount={() => {
setPassword('');
setLoginModalOpen(true);
}}
onGoToSignPage={() => navigateToPage('sign')}
/>
) : (
<SignPage
file={selectedIpaFile}
onFileChange={handleFileChange}
accounts={savedAccounts}
activeAccountKey={activeAccountKey}
onAccountChange={(key) => {
const summary = savedAccounts.find((a) => accountKey(a.appleId, a.teamId) === key);
if (summary) handleSwitchAccount(summary);
}}
knownUdids={knownUdids}
connectedUdid={pairedDeviceInfo?.udid ?? null}
selectedUdid={selectedTargetUdid}
onSelectedUdidChange={handleSelectedUdidChange}
onPair={handlePair}
pairBusy={busy.pair}
pairDisabled={pairDisabled}
onSign={handleSign}
signBusy={busy.sign}
signDisabled={signDisabled}
onInstall={handleInstall}
installBusy={busy.install}
installDisabled={installDisabled}
/>
)}
</section>
<LoginModal
open={loginModalOpen}
onClose={() => setLoginModalOpen(false)}
appleId={appleId}
password={password}
busyLoginSign={busy.loginSign}
canSubmit={loginCanSubmit}
onAppleIdChange={setAppleId}
onAppleIdBlur={handleAppleIdBlur}
onPasswordChange={setPassword}
onSubmit={handleLogin}
/>
<ProgressCard
percent={progress.percent}
status={progress.status}
busy={progressBusy}
logLines={logLines}
onDismiss={handleDismissProgress}
/>
<TrustModal open={trustOpen} onClose={() => setTrustOpen(false)} pairing={busy.pair} />
<TwoFactorModal open={twoFactor.open} onSubmit={handleTwoFactorSubmit} onCancel={handleTwoFactorCancel} />
</main>
);
}

View File

@@ -1,42 +1,42 @@
import type { HttpClient } from "@lbr77/anisette-js"
import { initLibcurl } from "./anisette-libcurl-init"
import { requireLibcurl } from "./wasm/libcurl"
import type { HttpClient } from '@lbr77/anisette-js';
import { initLibcurl } from './anisette-libcurl-init';
import { requireLibcurl } from './wasm/libcurl';
export class LibcurlHttpClient implements HttpClient {
async get(url: string, headers: Record<string, string>): Promise<Uint8Array> {
await initLibcurl()
const libcurl = requireLibcurl()
await initLibcurl();
const libcurl = requireLibcurl();
const response = await libcurl.fetch(url, {
method: "GET",
method: 'GET',
headers,
insecure: true,
_libcurl_http_version: 1.1,
})
});
if (!response.ok) {
throw new Error(`HTTP GET ${url} failed: ${response.status} ${response.statusText}`)
throw new Error(`HTTP GET ${url} failed: ${response.status} ${response.statusText}`);
}
return new Uint8Array(await response.arrayBuffer())
return new Uint8Array(await response.arrayBuffer());
}
async post(url: string, body: string, headers: Record<string, string>): Promise<Uint8Array> {
await initLibcurl()
const libcurl = requireLibcurl()
await initLibcurl();
const libcurl = requireLibcurl();
const response = await libcurl.fetch(url, {
method: "POST",
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}`)
throw new Error(`HTTP POST ${url} failed: ${response.status} ${response.statusText}`);
}
return new Uint8Array(await response.arrayBuffer())
return new Uint8Array(await response.arrayBuffer());
}
}

View File

@@ -1,26 +1,41 @@
import { loadLibcurl, libcurl } from "./wasm/libcurl"
import { loadLibcurl, libcurl } from './wasm/libcurl';
let initialized = false
let initPromise: Promise<void> | null = null
let initialized = false;
let initPromise: Promise<void> | null = null;
export async function initLibcurl(): Promise<void> {
if (initialized) {
return
return;
}
if (initPromise) {
return initPromise
return initPromise;
}
initPromise = (async () => {
const loadedLibcurl = await loadLibcurl()
const wsProto = location.protocol === "https:" ? "wss:" : "ws:"
const wsUrl = `${wsProto}//${location.host}/wisp/`
loadedLibcurl.set_websocket(wsUrl)
await loadedLibcurl.load_wasm()
initialized = true
})()
let loadedLibcurl;
try {
loadedLibcurl = await loadLibcurl();
} catch (error) {
initPromise = null;
throw new Error(`Failed to load libcurl WASM module. ${error instanceof Error ? error.message : String(error)}`);
}
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProto}//${location.host}/wisp/`;
loadedLibcurl.set_websocket(wsUrl);
try {
await loadedLibcurl.load_wasm();
} catch (error) {
initPromise = null;
throw new Error(
`Failed to initialize libcurl (is the WISP backend running?). ${
error instanceof Error ? error.message : String(error)
}`,
);
}
initialized = true;
})();
return initPromise
return initPromise;
}
export { libcurl }
export { libcurl };

View File

@@ -1,76 +1,80 @@
import { Anisette, loadWasmModule } from "@lbr77/anisette-js"
import { initLibcurl } from "./anisette-libcurl-init"
import { LibcurlHttpClient } from "./anisette-libcurl-http"
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
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
let anisetteInstance: Anisette | null = null;
export async function initAnisette(): Promise<Anisette> {
if (anisetteInstance) {
return anisetteInstance
return anisetteInstance;
}
await initLibcurl()
const httpClient = new LibcurlHttpClient()
await initLibcurl();
const httpClient = new LibcurlHttpClient();
const wasmModule = await loadWasmModule()
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)),
])
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/",
libraryPath: './anisette/',
},
})
});
return anisetteInstance
return anisetteInstance;
}
export async function provisionAnisette(): Promise<void> {
const anisette = await initAnisette()
const anisette = await initAnisette();
if (!anisette.isProvisioned) {
await anisette.provision()
await anisette.provision();
}
}
export async function getAnisetteData(): Promise<AnisetteData> {
const anisette = await initAnisette()
const anisette = await initAnisette();
if (!anisette.isProvisioned) {
await anisette.provision()
await anisette.provision();
}
const headers = await anisette.getData()
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"],
}
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
anisetteInstance = null;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,87 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { DevicePicker } from './DevicePicker';
describe('DevicePicker', () => {
it('shows the default placeholder text when no UDIDs exist', () => {
render(
<DevicePicker
knownUdids={[]}
connectedUdid={null}
selectedUdid=""
onSelectedChange={() => {}}
onPair={() => {}}
pairing={false}
pairDisabled={false}
/>,
);
// "Connected:" is hidden when no device is connected.
expect(screen.queryByText(/Connected:/)).not.toBeInTheDocument();
expect(screen.getByRole('combobox')).toHaveDisplayValue('No paired device');
});
it('lists known UDIDs and echoes the selected value', () => {
render(
<DevicePicker
knownUdids={['UDID-A', 'UDID-B']}
connectedUdid="UDID-A"
selectedUdid="UDID-A"
onSelectedChange={() => {}}
onPair={() => {}}
pairing={false}
pairDisabled={false}
/>,
);
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.value).toBe('UDID-A');
expect(screen.getAllByRole('option')).toHaveLength(3); // placeholder + 2
});
it('calls onSelectedChange when the user picks a UDID', async () => {
const onChange = vi.fn();
render(
<DevicePicker
knownUdids={['UDID-A', 'UDID-B']}
connectedUdid={null}
selectedUdid=""
onSelectedChange={onChange}
onPair={() => {}}
pairing={false}
pairDisabled={false}
/>,
);
await userEvent.selectOptions(screen.getByRole('combobox'), 'UDID-B');
expect(onChange).toHaveBeenCalledWith('UDID-B');
});
it('fires onPair when the pair button is clicked (and is disabled / busy on demand)', async () => {
const onPair = vi.fn();
const { rerender } = render(
<DevicePicker
knownUdids={[]}
connectedUdid={null}
selectedUdid=""
onSelectedChange={() => {}}
onPair={onPair}
pairing={false}
pairDisabled={false}
/>,
);
await userEvent.click(screen.getByRole('button', { name: 'Connect Device' }));
expect(onPair).toHaveBeenCalled();
rerender(
<DevicePicker
knownUdids={[]}
connectedUdid={null}
selectedUdid=""
onSelectedChange={() => {}}
onPair={onPair}
pairing
pairDisabled
/>,
);
expect(screen.getByRole('button', { name: /Connecting/ })).toBeDisabled();
});
});

View File

@@ -0,0 +1,52 @@
import { Button } from './ui/Button';
interface DevicePickerProps {
knownUdids: string[];
connectedUdid: string | null;
selectedUdid: string;
onSelectedChange: (udid: string) => void;
onPair: () => void;
pairing: boolean;
pairDisabled: boolean;
}
export function DevicePicker({
knownUdids,
connectedUdid,
selectedUdid,
onSelectedChange,
onPair,
pairing,
pairDisabled,
}: DevicePickerProps) {
return (
<div>
<label htmlFor="device-udid-select" className="mb-1.5 block text-[12.5px] font-medium text-muted">
Target Device
</label>
<div className="grid gap-3 sm:grid-cols-[1fr_auto]">
<select
id="device-udid-select"
className="field-input field-select"
value={selectedUdid}
onChange={(e) => onSelectedChange(e.target.value)}
>
<option value="">{knownUdids.length > 0 ? 'Select paired device' : 'No paired device'}</option>
{knownUdids.map((udid) => (
<option key={udid} value={udid}>
{udid}
</option>
))}
</select>
<Button variant="ghost" busy={pairing} busyLabel="Connecting…" disabled={pairDisabled} onClick={onPair}>
Connect Device
</Button>
</div>
{connectedUdid && (
<p className="mt-1.5 font-mono text-[11px] text-subtle">
Connected: <code className="text-muted">{connectedUdid}</code>
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { DropZone } from './DropZone';
function makeIpa(name = 'app.ipa', size = 2 * 1024 * 1024): File {
return new File([new Uint8Array(size)], name, { type: 'application/octet-stream' });
}
describe('DropZone', () => {
it('shows the empty state by default', () => {
render(<DropZone file={null} onFileChange={() => {}} />);
expect(screen.getByText('No file selected')).toBeInTheDocument();
expect(screen.getByText(/Click or drag \.ipa here/)).toBeInTheDocument();
});
it('shows the file name and size when a file is selected', () => {
render(<DropZone file={makeIpa('demo.ipa', 2 * 1024 * 1024)} onFileChange={() => {}} />);
expect(screen.getByText('demo.ipa')).toBeInTheDocument();
expect(screen.getByText(/2\.00 MB/)).toBeInTheDocument();
});
it('fires onFileChange(null) when the Clear button is clicked', async () => {
const onChange = vi.fn();
render(<DropZone file={makeIpa()} onFileChange={onChange} />);
await userEvent.click(screen.getByText('Clear'));
expect(onChange).toHaveBeenCalledWith(null);
});
it('adds the `dragover` class on dragenter/dragover and removes on dragleave', () => {
render(<DropZone file={null} onFileChange={() => {}} />);
const zone = screen.getByText('No file selected').closest('label')!;
expect(zone).not.toHaveClass('dragover');
fireEvent.dragEnter(zone);
expect(zone).toHaveClass('dragover');
fireEvent.dragLeave(zone);
expect(zone).not.toHaveClass('dragover');
});
it('accepts a dropped file via the drop event', () => {
const onChange = vi.fn();
render(<DropZone file={null} onFileChange={onChange} />);
const zone = screen.getByText('No file selected').closest('label')!;
const dropped = makeIpa('drop.ipa', 100);
fireEvent.drop(zone, {
dataTransfer: { files: [dropped] },
});
expect(onChange).toHaveBeenCalledWith(dropped);
});
it('accepts a selected file via the hidden input', () => {
const onChange = vi.fn();
render(<DropZone file={null} onFileChange={onChange} />);
const input = document.querySelector<HTMLInputElement>('input[type="file"]')!;
const selected = makeIpa('picked.ipa', 50);
fireEvent.change(input, { target: { files: [selected] } });
expect(onChange).toHaveBeenCalledWith(selected);
});
});

View File

@@ -0,0 +1,88 @@
import { useState, type ChangeEvent, type DragEvent, type MouseEvent } from 'react';
import { formatFileSize } from '../lib/ids';
interface DropZoneProps {
file: File | null;
onFileChange: (file: File | null) => void;
accept?: string;
}
export function DropZone({ file, onFileChange, accept = '.ipa,application/octet-stream' }: DropZoneProps) {
const [isDragOver, setIsDragOver] = useState(false);
const handleDragOver = (event: DragEvent<HTMLLabelElement>) => {
event.preventDefault();
setIsDragOver(true);
};
const handleDragLeave = () => {
setIsDragOver(false);
};
const handleDrop = (event: DragEvent<HTMLLabelElement>) => {
event.preventDefault();
setIsDragOver(false);
const dropped = event.dataTransfer?.files?.[0] ?? null;
if (dropped) onFileChange(dropped);
};
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
const selected = event.target.files?.[0] ?? null;
onFileChange(selected);
};
const handleClear = (event: MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
onFileChange(null);
};
return (
<div>
<label
className={`drop-zone${isDragOver ? ' dragover' : ''}`}
onDragEnter={handleDragOver}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<svg
className="mx-auto mb-2 h-7 w-7 text-subtle"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z" />
<path d="M12 11v6m-3-3 3 3 3-3" />
</svg>
{file ? (
<>
<span className="block truncate text-[14px] font-medium text-ink">{file.name}</span>
<span className="mt-1 block text-[12px] text-muted">
{formatFileSize(file.size)}
<button
type="button"
onClick={handleClear}
className="ml-2 text-[var(--color-accent)] underline-offset-2 hover:underline"
>
Clear
</button>
</span>
</>
) : (
<>
<span className="block text-[14px] font-medium text-ink">No file selected</span>
<span className="mt-1 block text-[12px] text-muted">Click or drag .ipa here</span>
</>
)}
<input type="file" accept={accept} className="hidden" onChange={handleInputChange} value="" />
</label>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { Header } from './Header';
describe('Header', () => {
it('reflects the active page via data-active', () => {
render(<Header currentPage="login" onNavigate={() => {}} />);
expect(screen.getByRole('button', { name: 'Account' })).toHaveAttribute('data-active', 'true');
expect(screen.getByRole('button', { name: /Sign & Install/ })).toHaveAttribute('data-active', 'false');
});
it('calls onNavigate when clicking the nav buttons', async () => {
const onNav = vi.fn();
render(<Header currentPage="login" onNavigate={onNav} />);
await userEvent.click(screen.getByRole('button', { name: /Sign & Install/ }));
expect(onNav).toHaveBeenCalledWith('sign');
await userEvent.click(screen.getByRole('button', { name: 'Account' }));
expect(onNav).toHaveBeenCalledWith('login');
});
it('wordmark click also navigates to login (and prevents the anchor default)', async () => {
const onNav = vi.fn();
render(<Header currentPage="sign" onNavigate={onNav} />);
const link = screen.getByRole('link', { name: /AltStore Web/ });
await userEvent.click(link);
expect(onNav).toHaveBeenCalledWith('login');
// Hash should not have been mutated by the default anchor behavior.
expect(window.location.hash).toBe('');
});
});

View File

@@ -0,0 +1,46 @@
export type AppPage = 'login' | 'sign';
interface HeaderProps {
currentPage: AppPage;
onNavigate: (page: AppPage) => void;
}
export function Header({ currentPage, onNavigate }: HeaderProps) {
return (
<header className="border-b border-border">
<div className="mx-auto flex max-w-[760px] items-center justify-between px-5 py-4 sm:px-7">
<a
href="#/login"
onClick={(e) => {
e.preventDefault();
onNavigate('login');
}}
className="flex items-center gap-2 text-[15px] font-semibold tracking-tight text-ink no-underline"
>
<span className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-ink text-[10px] font-bold text-white">
A
</span>
AltStore Web
</a>
<nav className="seg" aria-label="Page">
<button
type="button"
className="seg-btn"
data-active={currentPage === 'login'}
onClick={() => onNavigate('login')}
>
Account
</button>
<button
type="button"
className="seg-btn"
data-active={currentPage === 'sign'}
onClick={() => onNavigate('sign')}
>
Sign &amp; Install
</button>
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,106 @@
import type { ChangeEvent } from 'react';
import { Button } from './ui/Button';
import { Field } from './ui/Field';
interface LoginModalProps {
open: boolean;
onClose: () => void;
appleId: string;
password: string;
busyLoginSign: boolean;
canSubmit: boolean;
onAppleIdChange: (value: string) => void;
onAppleIdBlur: () => void;
onPasswordChange: (value: string) => void;
onSubmit: () => void;
}
export function LoginModal({
open,
onClose,
appleId,
password,
busyLoginSign,
canSubmit,
onAppleIdChange,
onAppleIdBlur,
onPasswordChange,
onSubmit,
}: LoginModalProps) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/30 backdrop-blur-sm p-4 pt-[8vh]">
<div className="w-full max-w-[440px] rounded-2xl border border-border bg-bg p-6 shadow-2xl anim-in">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-[18px] font-semibold tracking-tight text-ink">Add Account</h2>
<button
type="button"
onClick={onClose}
disabled={busyLoginSign}
className="flex h-7 w-7 items-center justify-center rounded-full text-muted transition-colors hover:bg-surface hover:text-ink disabled:opacity-40"
>
<svg
className="h-4 w-4"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
>
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</div>
<div className="space-y-4">
<Field
label="Apple ID"
type="email"
autoComplete="username"
placeholder="you@icloud.com"
value={appleId}
onChange={(e: ChangeEvent<HTMLInputElement>) => onAppleIdChange(e.target.value)}
onBlur={onAppleIdBlur}
/>
<Field
label="Password"
type="password"
autoComplete="current-password"
placeholder="Apple ID password"
value={password}
onChange={(e: ChangeEvent<HTMLInputElement>) => onPasswordChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && canSubmit && !busyLoginSign) {
e.preventDefault();
onSubmit();
}
}}
/>
</div>
<div className="mt-4 space-y-1">
<p className="text-[11.5px] text-muted">
Your credentials are stored locally in this browser and are sent directly to Apple.
</p>
<p className="text-[11.5px] text-[var(--color-danger)] underline underline-offset-2 decoration-[var(--color-danger)]/40">
Verify that you trust the server hosting this page. A compromised server can intercept your credentials.
</p>
</div>
<div className="mt-5 flex justify-end">
<Button
variant="primary"
busy={busyLoginSign}
busyLabel="Signing In…"
disabled={!canSubmit}
onClick={onSubmit}
className="min-w-[140px]"
>
Sign In
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { LoginPage } from './LoginPage';
import type { StoredAccountSummary } from '../lib/account-session';
type Props = Parameters<typeof LoginPage>[0];
function defaultProps(overrides: Partial<Props> = {}): Props {
const noop = () => {};
return {
loggedIn: false,
savedAccounts: [],
activeAccountKey: null,
cachedAccountKeys: new Set(),
onSwitchAccount: noop,
onDeleteAccount: noop,
onAddAccount: noop,
onGoToSignPage: noop,
...overrides,
};
}
describe('LoginPage', () => {
it('renders heading and Add Account button', () => {
render(<LoginPage {...defaultProps()} />);
expect(screen.getByRole('heading', { name: 'Accounts' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Add Account' })).toBeInTheDocument();
});
it('shows empty state when no accounts', () => {
render(<LoginPage {...defaultProps()} />);
expect(screen.getByText('No accounts yet.')).toBeInTheDocument();
});
it('shows Sign & Install link when logged in', () => {
render(<LoginPage {...defaultProps({ loggedIn: true })} />);
expect(screen.getByRole('button', { name: /Sign & Install/ })).toBeInTheDocument();
});
it('fires onAddAccount when clicking Add Account', async () => {
const onAdd = vi.fn();
render(<LoginPage {...defaultProps({ onAddAccount: onAdd })} />);
await userEvent.click(screen.getByRole('button', { name: 'Add Account' }));
expect(onAdd).toHaveBeenCalled();
});
it('forwards saved accounts and propagates onSwitch + onDelete', async () => {
const onSwitch = vi.fn();
const onDelete = vi.fn();
const acct: StoredAccountSummary = {
appleId: 'u@e.com',
teamId: 'T1',
teamName: 'Team',
updatedAtIso: '2024-01-01T00:00:00.000Z',
};
render(
<LoginPage
{...defaultProps({
savedAccounts: [acct],
activeAccountKey: null,
cachedAccountKeys: new Set(),
onSwitchAccount: onSwitch,
onDeleteAccount: onDelete,
})}
/>,
);
await userEvent.click(screen.getByRole('button', { name: 'Re-Login' }));
expect(onSwitch).toHaveBeenCalledWith(acct);
});
});

View File

@@ -0,0 +1,55 @@
import { Button } from './ui/Button';
import { SavedAccountsList } from './SavedAccountsList';
import type { StoredAccountSummary } from '../lib/account-session';
interface LoginPageProps {
loggedIn: boolean;
savedAccounts: StoredAccountSummary[];
activeAccountKey: string | null;
cachedAccountKeys: Set<string>;
onSwitchAccount: (summary: StoredAccountSummary) => void;
onDeleteAccount: (summary: StoredAccountSummary) => void;
onAddAccount: () => void;
onGoToSignPage: () => void;
}
export function LoginPage({
loggedIn,
savedAccounts,
activeAccountKey,
cachedAccountKeys,
onSwitchAccount,
onDeleteAccount,
onAddAccount,
onGoToSignPage,
}: LoginPageProps) {
return (
<section className="space-y-6 anim-in">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-[clamp(1.75rem,3.5vw,2.1rem)] font-semibold tracking-tight text-ink">Accounts</h1>
<p className="mt-1 text-[14px] text-muted">Add your Apple Developer account here to sign and install apps.</p>
</div>
<Button variant="primary" onClick={onAddAccount} className="shrink-0">
Add Account
</Button>
</div>
<SavedAccountsList
accounts={savedAccounts}
activeKey={activeAccountKey}
cachedKeys={cachedAccountKeys}
onSwitch={onSwitchAccount}
onDelete={onDeleteAccount}
/>
{loggedIn && (
<div className="flex justify-end">
<Button variant="ghost" onClick={onGoToSignPage}>
Sign &amp; Install
</Button>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,43 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { ProgressCard } from './ProgressCard';
describe('ProgressCard', () => {
it('returns null when idle (not busy, percent=0)', () => {
const { container } = render(<ProgressCard percent={0} status="idle" busy={false} logLines={[]} />);
expect(container.innerHTML).toBe('');
});
it('renders full-screen modal when busy', () => {
const { container } = render(<ProgressCard percent={45} status="signing: team ready" busy logLines={[]} />);
expect(screen.getByText('Working…')).toBeInTheDocument();
expect(screen.getByText('signing: team ready · 45%')).toBeInTheDocument();
const fill = container.querySelector<HTMLDivElement>('.progress-fill')!;
expect(fill.style.width).toBe('45%');
expect(fill).toHaveAttribute('data-busy', 'true');
});
it('shows Done state with dismiss button when complete', async () => {
const onDismiss = vi.fn();
render(<ProgressCard percent={100} status="complete" busy={false} logLines={['done!']} onDismiss={onDismiss} />);
expect(screen.getByRole('heading', { name: 'Done' })).toBeInTheDocument();
expect(screen.getByText('Complete')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Done' }));
expect(onDismiss).toHaveBeenCalled();
});
it('shows Failed state with close button', async () => {
const onDismiss = vi.fn();
render(<ProgressCard percent={0} status="failed" busy={false} logLines={[]} onDismiss={onDismiss} />);
expect(screen.getByRole('heading', { name: 'Failed' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(onDismiss).toHaveBeenCalled();
});
it('auto-scrolls log to bottom', () => {
render(<ProgressCard percent={50} status="working" busy logLines={['a', 'b', 'c']} />);
const pre = document.querySelector('pre.log')!;
expect(pre.textContent).toBe('a\nb\nc');
});
});

View File

@@ -0,0 +1,113 @@
import { useEffect, useRef } from 'react';
interface ProgressCardProps {
percent: number;
status: string;
busy: boolean;
logLines: string[];
onDismiss?: () => void;
}
export function ProgressCard({ percent, status, busy, logLines, onDismiss }: ProgressCardProps) {
const clamped = Math.max(0, Math.min(100, percent));
const done = !busy && clamped === 100;
const failed = !busy && status === 'failed';
const showModal = busy || done || failed;
const logRef = useRef<HTMLPreElement>(null);
useEffect(() => {
if (logRef.current) {
logRef.current.scrollTop = logRef.current.scrollHeight;
}
}, [logLines]);
const statusText = busy ? `${status} · ${clamped}%` : done ? 'Complete' : failed ? 'Failed' : 'idle';
if (!showModal) {
return null;
}
return (
<div className="fixed inset-0 z-50 flex flex-col bg-bg/95 backdrop-blur-sm" style={{ opacity: 1 }}>
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<h2 className="text-[15px] font-semibold tracking-tight text-ink">
{busy ? 'Working…' : done ? 'Done' : 'Failed'}
</h2>
{!busy && (
<button
type="button"
onClick={onDismiss}
className="flex h-7 w-7 items-center justify-center rounded-full text-muted transition-colors hover:bg-surface hover:text-ink"
>
<svg
className="h-4 w-4"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
>
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
)}
</div>
<div className="mx-auto flex w-full max-w-[760px] flex-1 flex-col gap-6 overflow-hidden px-6 py-8">
<div className="text-center">
{busy && (
<div className="mx-auto mb-4 h-10 w-10 animate-spin rounded-full border-[3px] border-border border-t-ink" />
)}
{done && (
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--color-success-soft)]">
<svg
className="h-6 w-6 text-[var(--color-success)]"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
viewBox="0 0 24 24"
>
<path d="M5 13l4 4L19 7" />
</svg>
</div>
)}
{failed && (
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--color-danger-soft)]">
<svg
className="h-6 w-6 text-[var(--color-danger)]"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
viewBox="0 0 24 24"
>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
)}
<p className="font-mono text-[13px] text-muted">{statusText}</p>
</div>
<div className="progress-track" aria-hidden="true">
<div className="progress-fill" data-busy={busy ? 'true' : 'false'} style={{ width: `${clamped}%` }} />
</div>
<pre ref={logRef} className="log flex-1 min-h-0 overflow-auto">
{logLines.length > 0 ? logLines.join('\n') : 'Waiting for output…'}
</pre>
</div>
{!busy && (
<div className="border-t border-border px-6 py-4 text-center">
<button type="button" onClick={onDismiss} className="btn btn-primary min-w-[160px]">
{done ? 'Done' : 'Close'}
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { SavedAccountsList } from './SavedAccountsList';
import type { StoredAccountSummary } from '../lib/account-session';
const sampleAccounts: StoredAccountSummary[] = [
{
appleId: 'alpha@e.com',
teamId: 'T1',
teamName: 'Alpha Team',
updatedAtIso: '2024-01-01T00:00:00.000Z',
},
{
appleId: 'beta@e.com',
teamId: 'T2',
teamName: 'Beta Team',
updatedAtIso: 'not-a-date',
},
];
describe('SavedAccountsList', () => {
it('shows an empty state when there are no accounts', () => {
render(<SavedAccountsList accounts={[]} activeKey={null} cachedKeys={new Set()} onSwitch={() => {}} />);
expect(screen.getByText('No accounts yet.')).toBeInTheDocument();
});
it('renders each row with team info and the correct action', () => {
render(
<SavedAccountsList
accounts={sampleAccounts}
activeKey="alpha@e.com::T1"
cachedKeys={new Set(['alpha@e.com::T1'])}
onSwitch={() => {}}
/>,
);
expect(screen.getByText('alpha@e.com')).toBeInTheDocument();
expect(screen.getByText('beta@e.com')).toBeInTheDocument();
expect(screen.getByText('Active')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Re-Login' })).toBeEnabled();
});
it('falls back to the raw updated-at string when it fails to parse', () => {
render(<SavedAccountsList accounts={sampleAccounts} activeKey={null} cachedKeys={new Set()} onSwitch={() => {}} />);
expect(screen.getByText(/Beta Team · T2 · not-a-date/)).toBeInTheDocument();
});
it("uses 'Switch' when a cached session exists for a non-active row", () => {
render(
<SavedAccountsList
accounts={sampleAccounts}
activeKey={null}
cachedKeys={new Set(['beta@e.com::T2'])}
onSwitch={() => {}}
/>,
);
expect(screen.getByRole('button', { name: 'Switch' })).toBeEnabled();
});
it('calls onSwitch with the clicked summary', async () => {
const onSwitch = vi.fn();
render(<SavedAccountsList accounts={sampleAccounts} activeKey={null} cachedKeys={new Set()} onSwitch={onSwitch} />);
const buttons = screen.getAllByRole('button', { name: 'Re-Login' });
await userEvent.click(buttons[0]);
expect(onSwitch).toHaveBeenCalledWith(sampleAccounts[0]);
});
it('shows delete button and fires onDelete', async () => {
const onDelete = vi.fn();
render(
<SavedAccountsList
accounts={sampleAccounts}
activeKey={null}
cachedKeys={new Set()}
onSwitch={() => {}}
onDelete={onDelete}
/>,
);
const deleteButtons = screen.getAllByTitle('Remove account');
expect(deleteButtons).toHaveLength(2);
await userEvent.click(deleteButtons[0]);
expect(onDelete).toHaveBeenCalledWith(sampleAccounts[0]);
});
});

View File

@@ -0,0 +1,79 @@
import { Button } from './ui/Button';
import type { StoredAccountSummary } from '../lib/account-session';
interface SavedAccountsListProps {
accounts: StoredAccountSummary[];
activeKey: string | null;
cachedKeys: Set<string>;
onSwitch: (summary: StoredAccountSummary) => void;
onDelete?: (summary: StoredAccountSummary) => void;
}
function buildKey(summary: StoredAccountSummary): string {
return `${summary.appleId.trim().toLowerCase()}::${summary.teamId.trim().toUpperCase()}`;
}
function formatUpdatedAt(iso: string): string {
return Number.isNaN(Date.parse(iso)) ? iso : new Date(iso).toLocaleString();
}
export function SavedAccountsList({ accounts, activeKey, cachedKeys, onSwitch, onDelete }: SavedAccountsListProps) {
if (accounts.length === 0) {
return (
<div className="flex flex-col items-center py-8 text-center">
<p className="text-[14px] text-muted">No accounts yet.</p>
<p className="mt-1 text-[12.5px] text-subtle">Click "Add Account" to sign in.</p>
</div>
);
}
return (
<div>
{accounts.map((item) => {
const key = buildKey(item);
const isActive = key === activeKey;
const hasCachedSession = cachedKeys.has(key);
return (
<div key={key} className="acct-row" data-active={isActive ? 'true' : 'false'}>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-[13.5px] font-medium text-ink">{item.appleId}</p>
<p className="mt-0.5 text-[11.5px] text-muted">
{item.teamName} · {item.teamId} · {formatUpdatedAt(item.updatedAtIso)}
{hasCachedSession ? ' · session cached' : ''}
</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
{!isActive && (
<Button size="sm" variant="ghost" onClick={() => onSwitch(item)}>
{hasCachedSession ? 'Switch' : 'Re-Login'}
</Button>
)}
{isActive && <span className="px-2 text-[11.5px] font-medium text-[var(--color-success)]">Active</span>}
{onDelete && (
<button
type="button"
onClick={() => onDelete(item)}
className="flex h-7 w-7 items-center justify-center rounded-md text-subtle transition-colors hover:bg-[var(--color-danger-soft)] hover:text-[var(--color-danger)]"
title="Remove account"
>
<svg
className="h-3.5 w-3.5"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
>
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
)}
</div>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { SignPage } from './SignPage';
type Props = Parameters<typeof SignPage>[0];
function defaultProps(overrides: Partial<Props> = {}): Props {
const noop = () => {};
return {
file: null,
onFileChange: noop,
accounts: [],
activeAccountKey: null,
onAccountChange: noop,
knownUdids: [],
connectedUdid: null,
selectedUdid: '',
onSelectedUdidChange: noop,
onPair: noop,
pairBusy: false,
pairDisabled: false,
onSign: noop,
signBusy: false,
signDisabled: true,
onInstall: noop,
installBusy: false,
installDisabled: true,
...overrides,
};
}
describe('SignPage', () => {
it('renders heading, account selector, drop zone and action buttons', () => {
render(
<SignPage
{...defaultProps({
accounts: [{ appleId: 'u@e.com', teamId: 'T1', teamName: 'Team', updatedAtIso: '2024-01-01' }],
})}
/>,
);
expect(screen.getByRole('heading', { name: /Sign & Install/ })).toBeInTheDocument();
expect(screen.getByLabelText('Signing Account')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Connect Device' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Sign IPA' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Install Signed IPA' })).toBeInTheDocument();
});
it('propagates sign / install clicks when enabled', async () => {
const onSign = vi.fn();
const onInstall = vi.fn();
render(
<SignPage
{...defaultProps({
onSign,
onInstall,
signDisabled: false,
installDisabled: false,
})}
/>,
);
await userEvent.click(screen.getByRole('button', { name: 'Sign IPA' }));
expect(onSign).toHaveBeenCalled();
await userEvent.click(screen.getByRole('button', { name: 'Install Signed IPA' }));
expect(onInstall).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,121 @@
import { Button } from './ui/Button';
import { DropZone } from './DropZone';
import { DevicePicker } from './DevicePicker';
import type { StoredAccountSummary } from '../lib/account-session';
interface SignPageProps {
file: File | null;
onFileChange: (file: File | null) => void;
accounts: StoredAccountSummary[];
activeAccountKey: string | null;
onAccountChange: (key: string) => void;
knownUdids: string[];
connectedUdid: string | null;
selectedUdid: string;
onSelectedUdidChange: (value: string) => void;
onPair: () => void;
pairBusy: boolean;
pairDisabled: boolean;
onSign: () => void;
signBusy: boolean;
signDisabled: boolean;
onInstall: () => void;
installBusy: boolean;
installDisabled: boolean;
}
function accountKey(s: StoredAccountSummary): string {
return `${s.appleId.trim().toLowerCase()}::${s.teamId.trim().toUpperCase()}`;
}
export function SignPage({
file,
onFileChange,
accounts,
activeAccountKey,
onAccountChange,
knownUdids,
connectedUdid,
selectedUdid,
onSelectedUdidChange,
onPair,
pairBusy,
pairDisabled,
onSign,
signBusy,
signDisabled,
onInstall,
installBusy,
installDisabled,
}: SignPageProps) {
return (
<section className="space-y-6 anim-in">
<div>
<h1 className="text-[clamp(1.75rem,3.5vw,2.1rem)] font-semibold tracking-tight text-ink">Sign &amp; Install</h1>
<p className="mt-2 text-[14.5px] text-muted">Drop an .ipa, then sign and install onto your paired device.</p>
</div>
<DropZone file={file} onFileChange={onFileChange} />
<div>
<label htmlFor="account-select" className="mb-1.5 block text-[12.5px] font-medium text-muted">
Signing Account
</label>
<select
id="account-select"
className="field-input field-select"
value={activeAccountKey ?? ''}
onChange={(e) => onAccountChange(e.target.value)}
>
<option value="">{accounts.length > 0 ? 'Select account' : 'No account'}</option>
{accounts.map((acct) => {
const key = accountKey(acct);
return (
<option key={key} value={key}>
{acct.appleId} / {acct.teamId}
</option>
);
})}
</select>
</div>
<DevicePicker
knownUdids={knownUdids}
connectedUdid={connectedUdid}
selectedUdid={selectedUdid}
onSelectedChange={onSelectedUdidChange}
onPair={onPair}
pairing={pairBusy}
pairDisabled={pairDisabled}
/>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="ghost"
busy={signBusy}
busyLabel="Signing…"
disabled={signDisabled}
onClick={onSign}
className="min-w-[120px]"
>
Sign IPA
</Button>
<Button
variant="primary"
busy={installBusy}
busyLabel="Installing…"
disabled={installDisabled}
onClick={onInstall}
className="min-w-[160px]"
>
Install Signed IPA
</Button>
</div>
</section>
);
}

View File

@@ -0,0 +1,49 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Stepper } from './Stepper';
describe('Stepper', () => {
it('renders labels and numbered markers for idle / active states', () => {
render(
<Stepper
steps={[
{ label: 'One', state: 'active' },
{ label: 'Two', state: 'idle' },
]}
/>,
);
expect(screen.getByText('One')).toBeInTheDocument();
expect(screen.getByText('Two')).toBeInTheDocument();
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
});
it('shows a checkmark for done steps', () => {
render(
<Stepper
steps={[
{ label: 'One', state: 'done' },
{ label: 'Two', state: 'active' },
]}
/>,
);
expect(screen.getByText('✓')).toBeInTheDocument();
});
it('sets data-state on each step for CSS styling', () => {
render(
<Stepper
steps={[
{ label: 'A', state: 'active' },
{ label: 'B', state: 'done' },
{ label: 'C', state: 'idle' },
]}
/>,
);
const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(3);
expect(items[0]).toHaveAttribute('data-state', 'active');
expect(items[1]).toHaveAttribute('data-state', 'done');
expect(items[2]).toHaveAttribute('data-state', 'idle');
});
});

View File

@@ -0,0 +1,45 @@
type StepState = 'idle' | 'active' | 'done';
interface Step {
label: string;
state: StepState;
}
interface StepperProps {
steps: Step[];
}
function StepMarker({ state, index }: { state: StepState; index: number }) {
if (state === 'done') {
return (
<span className="stepper-marker" aria-hidden="true">
</span>
);
}
return (
<span className="stepper-marker" aria-hidden="true">
{index + 1}
</span>
);
}
export function Stepper({ steps }: StepperProps) {
return (
<div className="stepper" role="list">
{steps.map((step, index) => {
const isLast = index === steps.length - 1;
const nextStepDone = !isLast && steps[index].state === 'done';
return (
<div key={step.label} className="contents">
<div className="stepper-step" role="listitem" data-state={step.state}>
<StepMarker state={step.state} index={index} />
<span className="stepper-label">{step.label}</span>
</div>
{!isLast ? <div className="stepper-bar" data-done={nextStepDone ? 'true' : 'false'} /> : null}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { TrustModal } from './TrustModal';
describe('TrustModal', () => {
it('is hidden when closed', () => {
const { container } = render(<TrustModal open={false} onClose={() => {}} pairing={false} />);
const modal = container.querySelector('.modal');
expect(modal).toHaveAttribute('aria-hidden', 'true');
});
it('shows pairing state with spinner when pairing', () => {
render(<TrustModal open onClose={() => {}} pairing />);
expect(screen.getByText('Continue on Your Device')).toBeInTheDocument();
expect(screen.getByText('Waiting for device…')).toBeInTheDocument();
});
it('shows success state with Continue button when paired', async () => {
const onClose = vi.fn();
render(<TrustModal open onClose={onClose} pairing={false} />);
expect(screen.getByText('Device Paired')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,60 @@
import { Modal } from './ui/Modal';
interface TrustModalProps {
open: boolean;
onClose: () => void;
pairing: boolean;
}
export function TrustModal({ open, onClose, pairing }: TrustModalProps) {
return (
<Modal open={open} onClose={onClose} labelledBy="trust-title" closeOnBackdrop={false} closeOnEscape={!pairing}>
<div className="flex flex-col items-center text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-surface" aria-hidden="true">
<svg
className="h-7 w-7 text-ink"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="6" y="2.5" width="12" height="19" rx="2.5" />
<path d="M11 18.5h2" />
<path d="M9 7.5l2.2 2.2L15 5.9" />
</svg>
</div>
{pairing ? (
<>
<h2 id="trust-title" className="text-[16px] font-semibold tracking-tight text-ink">
Continue on Your Device
</h2>
<p className="mt-2 text-[13px] leading-[1.6] text-muted">
If prompted, unlock your iPhone or iPad and tap <strong className="font-semibold text-ink">Trust</strong>.
Enter your passcode if asked.
</p>
<p className="mt-1.5 text-[12px] leading-[1.5] text-subtle">
Developer Mode must be enabled on your device. Go to Settings Privacy & Security Developer Mode.
</p>
<div className="mt-5 flex items-center gap-2 text-[12.5px] text-muted">
<span className="spinner" aria-hidden="true" />
<span>Waiting for device</span>
</div>
</>
) : (
<>
<h2 id="trust-title" className="text-[16px] font-semibold tracking-tight text-ink">
Device Paired
</h2>
<p className="mt-2 text-[13px] leading-[1.6] text-muted">Your device is connected and ready for signing.</p>
<button type="button" onClick={onClose} className="btn btn-primary mt-5 w-full">
Continue
</button>
</>
)}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,58 @@
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { TwoFactorModal } from './TwoFactorModal';
describe('TwoFactorModal', () => {
it('is hidden when closed', () => {
const { container } = render(<TwoFactorModal open={false} onSubmit={() => {}} onCancel={() => {}} />);
expect(container.querySelector('.modal')).not.toHaveClass('open');
});
it('focuses the input when opened', async () => {
render(<TwoFactorModal open onSubmit={() => {}} onCancel={() => {}} />);
// useEffect runs a setTimeout(0) before focusing — let it tick.
await new Promise((resolve) => setTimeout(resolve, 10));
expect(screen.getByLabelText('Verification Code')).toHaveFocus();
});
it('shows an inline error when submitting an empty code', async () => {
const onSubmit = vi.fn();
render(<TwoFactorModal open onSubmit={onSubmit} onCancel={() => {}} />);
await userEvent.click(screen.getByRole('button', { name: 'Verify' }));
expect(screen.getByText('Please enter verification code.')).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
it('clears the error once the user starts typing', async () => {
render(<TwoFactorModal open onSubmit={() => {}} onCancel={() => {}} />);
await userEvent.click(screen.getByRole('button', { name: 'Verify' }));
expect(screen.getByText('Please enter verification code.')).toBeInTheDocument();
await userEvent.type(screen.getByLabelText('Verification Code'), '1');
expect(screen.queryByText('Please enter verification code.')).not.toBeInTheDocument();
});
it('submits a trimmed code on Enter', async () => {
const onSubmit = vi.fn();
render(<TwoFactorModal open onSubmit={onSubmit} onCancel={() => {}} />);
const input = screen.getByLabelText('Verification Code');
await userEvent.type(input, ' 123456 ');
fireEvent.keyDown(input, { key: 'Enter' });
expect(onSubmit).toHaveBeenLastCalledWith('123456');
});
it('submits via the Verify button', async () => {
const onSubmit = vi.fn();
render(<TwoFactorModal open onSubmit={onSubmit} onCancel={() => {}} />);
await userEvent.type(screen.getByLabelText('Verification Code'), '654321');
await userEvent.click(screen.getByRole('button', { name: 'Verify' }));
expect(onSubmit).toHaveBeenLastCalledWith('654321');
});
it('calls onCancel when Cancel is clicked', async () => {
const onCancel = vi.fn();
render(<TwoFactorModal open onSubmit={() => {}} onCancel={onCancel} />);
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,82 @@
import { useEffect, useRef, useState } from 'react';
import { Modal } from './ui/Modal';
import { Button } from './ui/Button';
interface TwoFactorModalProps {
open: boolean;
onSubmit: (code: string) => void;
onCancel: () => void;
}
export function TwoFactorModal({ open, onSubmit, onCancel }: TwoFactorModalProps) {
const [code, setCode] = useState('');
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) {
setCode('');
setError(null);
const timer = window.setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
return () => window.clearTimeout(timer);
}
}, [open]);
const handleSubmit = () => {
const trimmed = code.trim();
if (trimmed.length === 0) {
setError('Please enter verification code.');
return;
}
onSubmit(trimmed);
};
return (
<Modal open={open} onClose={onCancel} labelledBy="two-factor-title" closeOnBackdrop={false}>
<h2 id="two-factor-title" className="text-[16px] font-semibold tracking-tight text-ink">
Two-Factor Authentication
</h2>
<p className="mt-1.5 text-[13px] leading-[1.55] text-muted">
Enter the verification code from your trusted Apple device.
</p>
<label htmlFor="two-factor-code" className="mt-5 mb-1.5 block text-[12.5px] font-medium text-muted">
Verification Code
</label>
<input
ref={inputRef}
id="two-factor-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={8}
placeholder="123456"
className="field-input font-mono text-center text-[18px] tracking-[0.3em]"
value={code}
onChange={(e) => {
setCode(e.target.value);
if (error) setError(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSubmit();
}
}}
/>
<p className="mt-2 min-h-[18px] text-[12px] text-[var(--color-danger)]">{error ?? ''}</p>
<div className="mt-4 grid grid-cols-2 gap-2">
<Button variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button variant="primary" onClick={handleSubmit}>
Verify
</Button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,70 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { Button } from './Button';
describe('Button', () => {
it('renders the idle label and fires onClick', async () => {
const onClick = vi.fn();
render(
<Button variant="primary" onClick={onClick}>
Go
</Button>,
);
const btn = screen.getByRole('button', { name: 'Go' });
expect(btn).toHaveClass('btn', 'btn-primary');
await userEvent.click(btn);
expect(onClick).toHaveBeenCalledTimes(1);
});
it('shows the busy label + spinner and is disabled while busy', () => {
render(
<Button variant="primary" busy busyLabel="Working…">
Go
</Button>,
);
const btn = screen.getByRole('button', { name: /Working/ });
expect(btn).toBeDisabled();
expect(btn.querySelector('.spinner')).toBeInTheDocument();
});
it('honors the disabled prop and suppresses onClick', async () => {
const onClick = vi.fn();
render(
<Button variant="ghost" disabled onClick={onClick}>
Go
</Button>,
);
const btn = screen.getByRole('button', { name: 'Go' });
expect(btn).toBeDisabled();
await userEvent.click(btn).catch(() => {
/* user-event may throw on disabled — that's fine */
});
expect(onClick).not.toHaveBeenCalled();
});
it('applies ghost and accent variant classes', () => {
const { rerender } = render(<Button variant="ghost">a</Button>);
expect(screen.getByRole('button')).toHaveClass('btn-ghost');
rerender(<Button variant="accent">a</Button>);
expect(screen.getByRole('button')).toHaveClass('btn-accent');
});
it('applies the small size class when size="sm"', () => {
render(
<Button variant="ghost" size="sm">
small
</Button>,
);
expect(screen.getByRole('button')).toHaveClass('btn-sm');
});
it('falls back to "Working…" when no busyLabel provided', () => {
render(
<Button variant="primary" busy>
Go
</Button>,
);
expect(screen.getByRole('button')).toHaveTextContent('Working…');
});
});

View File

@@ -0,0 +1,45 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
type Variant = 'primary' | 'ghost' | 'accent';
interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type'> {
variant?: Variant;
busy?: boolean;
busyLabel?: string;
size?: 'default' | 'sm';
children: ReactNode;
}
const variantClass: Record<Variant, string> = {
primary: 'btn-primary',
ghost: 'btn-ghost',
accent: 'btn-accent',
};
export function Button({
variant = 'ghost',
busy = false,
busyLabel,
size = 'default',
className,
disabled,
children,
...rest
}: ButtonProps) {
const classes = ['btn', variantClass[variant]];
if (size === 'sm') classes.push('btn-sm');
if (className) classes.push(className);
return (
<button type="button" disabled={busy || disabled} className={classes.join(' ')} {...rest}>
{busy ? (
<>
<span className="spinner" aria-hidden="true" />
<span>{busyLabel ?? 'Working…'}</span>
</>
) : (
children
)}
</button>
);
}

View File

@@ -0,0 +1,23 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Chip } from './Chip';
describe('Chip', () => {
it('renders default tone without a data-tone attr', () => {
render(<Chip>Default</Chip>);
const el = screen.getByText('Default');
expect(el).toHaveClass('chip');
expect(el.hasAttribute('data-tone')).toBe(false);
});
it('sets data-tone for non-default tones', () => {
const { rerender } = render(<Chip tone="success">Ok</Chip>);
expect(screen.getByText('Ok')).toHaveAttribute('data-tone', 'success');
rerender(<Chip tone="accent">Accent</Chip>);
expect(screen.getByText('Accent')).toHaveAttribute('data-tone', 'accent');
rerender(<Chip tone="danger">Err</Chip>);
expect(screen.getByText('Err')).toHaveAttribute('data-tone', 'danger');
});
});

View File

@@ -0,0 +1,12 @@
import type { ReactNode } from 'react';
type Tone = 'default' | 'success' | 'accent' | 'danger';
export function Chip({ tone = 'default', children }: { tone?: Tone; children: ReactNode }) {
const dataTone = tone === 'default' ? undefined : tone;
return (
<span className="chip" data-tone={dataTone}>
{children}
</span>
);
}

View File

@@ -0,0 +1,36 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { Field } from './Field';
describe('Field', () => {
it('links the label to the input via htmlFor / id', () => {
render(<Field label="Email" />);
const input = screen.getByLabelText('Email');
expect(input).toBeInTheDocument();
});
it('forwards the provided id when given', () => {
render(<Field label="Email" id="my-email" />);
expect(screen.getByLabelText('Email')).toHaveAttribute('id', 'my-email');
});
it('renders a hint when no error is present', () => {
render(<Field label="N" hint="help text" />);
expect(screen.getByText('help text')).toBeInTheDocument();
});
it('renders the error instead of the hint and marks aria-invalid', () => {
render(<Field label="N" hint="ignored" error="bad" />);
expect(screen.queryByText('ignored')).not.toBeInTheDocument();
expect(screen.getByText('bad')).toBeInTheDocument();
expect(screen.getByLabelText('N')).toHaveAttribute('aria-invalid', 'true');
});
it('fires onChange when typing', async () => {
const onChange = vi.fn();
render(<Field label="N" onChange={onChange} defaultValue="" />);
await userEvent.type(screen.getByLabelText('N'), 'a');
expect(onChange).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,42 @@
import { forwardRef, useId, type InputHTMLAttributes, type ReactNode } from 'react';
interface FieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'className'> {
label: string;
hint?: ReactNode;
error?: ReactNode;
inputClassName?: string;
}
export const Field = forwardRef<HTMLInputElement, FieldProps>(function Field(
{ label, hint, error, inputClassName, id, ...rest },
ref,
) {
const reactId = useId();
const fieldId = id ?? reactId;
const hintId = hint || error ? `${fieldId}-hint` : undefined;
return (
<div>
<label htmlFor={fieldId} className="mb-1.5 block text-[12.5px] font-medium text-muted">
{label}
</label>
<input
ref={ref}
id={fieldId}
className={['field-input', inputClassName].filter(Boolean).join(' ')}
aria-describedby={hintId}
aria-invalid={error ? true : undefined}
{...rest}
/>
{error ? (
<p id={hintId} className="mt-1.5 text-[12px] text-[var(--color-danger)]">
{error}
</p>
) : hint ? (
<p id={hintId} className="mt-1.5 text-[12px] text-muted">
{hint}
</p>
) : null}
</div>
);
});

View File

@@ -0,0 +1,87 @@
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { Modal } from './Modal';
describe('Modal', () => {
it('toggles the `open` class based on the prop', () => {
const { rerender } = render(
<Modal open={false} labelledBy="t">
<h2 id="t">Title</h2>
</Modal>,
);
// aria-hidden true when closed
const root = screen.getByRole('dialog', { hidden: true }).parentElement;
expect(root).toHaveAttribute('aria-hidden', 'true');
rerender(
<Modal open labelledBy="t">
<h2 id="t">Title</h2>
</Modal>,
);
const openRoot = screen.getByRole('dialog').parentElement;
expect(openRoot).toHaveAttribute('aria-hidden', 'false');
expect(openRoot).toHaveClass('open');
});
it('closes on Escape when closeOnEscape (default)', () => {
const onClose = vi.fn();
render(
<Modal open onClose={onClose} labelledBy="t">
<h2 id="t">Title</h2>
</Modal>,
);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
it('does not listen for Escape when closeOnEscape is false', () => {
const onClose = vi.fn();
render(
<Modal open onClose={onClose} labelledBy="t" closeOnEscape={false}>
<h2 id="t">Title</h2>
</Modal>,
);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).not.toHaveBeenCalled();
});
it('does not close on backdrop click by default', () => {
const onClose = vi.fn();
render(
<Modal open onClose={onClose} labelledBy="t">
<h2 id="t">Title</h2>
</Modal>,
);
const backdrop = screen.getByRole('dialog').parentElement!;
fireEvent.click(backdrop);
expect(onClose).not.toHaveBeenCalled();
});
it('closes on backdrop click when closeOnBackdrop is true', async () => {
const onClose = vi.fn();
render(
<Modal open onClose={onClose} labelledBy="t" closeOnBackdrop>
<h2 id="t">Title</h2>
</Modal>,
);
await userEvent.click(screen.getByText('Title'));
expect(onClose).not.toHaveBeenCalled();
const backdrop = screen.getByRole('dialog').parentElement!;
fireEvent.click(backdrop);
expect(onClose).toHaveBeenCalledTimes(1);
});
it('does not close on backdrop click when closeOnBackdrop is false', () => {
const onClose = vi.fn();
render(
<Modal open onClose={onClose} labelledBy="t" closeOnBackdrop={false}>
<h2 id="t">Title</h2>
</Modal>,
);
const backdrop = screen.getByRole('dialog').parentElement!;
fireEvent.click(backdrop);
expect(onClose).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,46 @@
import { useEffect, type ReactNode } from 'react';
interface ModalProps {
open: boolean;
onClose?: () => void;
labelledBy: string;
children: ReactNode;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
}
export function Modal({
open,
onClose,
labelledBy,
children,
closeOnBackdrop = false,
closeOnEscape = true,
}: ModalProps) {
useEffect(() => {
if (!open || !closeOnEscape || !onClose) return;
const handler = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open, closeOnEscape, onClose]);
return (
<div
className={`modal${open ? ' open' : ''}`}
aria-hidden={open ? 'false' : 'true'}
onClick={(event) => {
if (!closeOnBackdrop || !onClose) return;
if (event.target === event.currentTarget) onClose();
}}
>
<section role="dialog" aria-modal="true" aria-labelledby={labelledBy} className="modal-panel">
{children}
</section>
</div>
);
}

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 };
}

View File

@@ -0,0 +1,309 @@
import { beforeEach, describe, expect, it } from 'vitest';
import type { AnisetteData } from '../anisette-service';
import type { AppleDeveloperContext } from '../apple-signing';
import {
decodeAnisetteData,
encodeAnisetteData,
loadStoredAccountList,
loadStoredAccountSummary,
persistAccountSession,
persistAccountSummary,
readAccountSessionMap,
removeStoredAccountSession,
restorePersistedAccountContexts,
setStoredAccountSummary,
writeAccountSessionMap,
type StoredAccountSessionPayload,
type StoredAccountSummary,
type StoredAnisetteDataPayload,
} from './account-session';
import {
APPLE_ACCOUNT_LIST_STORAGE_KEY,
APPLE_ACCOUNT_SESSION_MAP_STORAGE_KEY,
APPLE_ACCOUNT_SUMMARY_STORAGE_KEY,
loadText,
saveText,
} from './storage';
const sampleAnisette: AnisetteData = {
machineID: 'MID',
oneTimePassword: 'OTP',
localUserID: 'LUID',
routingInfo: 17106176,
deviceUniqueIdentifier: 'DUI',
deviceDescription: 'desc',
deviceSerialNumber: '0',
date: new Date('2024-01-02T03:04:05.000Z'),
locale: 'en_US',
timeZone: 'UTC',
};
function makeContext(overrides: Partial<AppleDeveloperContext> = {}): AppleDeveloperContext {
return {
appleId: 'user@example.com',
session: {
anisetteData: sampleAnisette,
dsid: 'dsid-1',
authToken: 'auth-1',
},
team: {
identifier: 'TEAMA',
name: 'Team A',
} as AppleDeveloperContext['team'],
certificates: [],
devices: [],
...overrides,
};
}
beforeEach(() => {
window.localStorage.clear();
});
describe('encode/decodeAnisetteData', () => {
it('round-trips a valid payload', () => {
const encoded = encodeAnisetteData(sampleAnisette);
const decoded = decodeAnisetteData(encoded);
expect(decoded).not.toBeNull();
expect(decoded?.machineID).toBe(sampleAnisette.machineID);
expect(decoded?.date.getTime()).toBe(sampleAnisette.date.getTime());
});
it('defaults a missing serial number to "0"', () => {
const encoded: StoredAnisetteDataPayload = {
...encodeAnisetteData(sampleAnisette),
deviceSerialNumber: '',
};
expect(decodeAnisetteData(encoded)?.deviceSerialNumber).toBe('0');
});
it('returns null when any required string field is empty', () => {
const encoded = encodeAnisetteData(sampleAnisette);
expect(decodeAnisetteData({ ...encoded, machineID: '' })).toBeNull();
expect(decodeAnisetteData({ ...encoded, deviceUniqueIdentifier: '' })).toBeNull();
expect(decodeAnisetteData({ ...encoded, timeZone: '' })).toBeNull();
});
it("returns null when routing info isn't finite", () => {
const encoded = encodeAnisetteData(sampleAnisette);
expect(decodeAnisetteData({ ...encoded, routingInfo: Number.NaN })).toBeNull();
});
it('returns null when the date is invalid', () => {
const encoded = encodeAnisetteData(sampleAnisette);
expect(decodeAnisetteData({ ...encoded, dateIso: 'not-a-date' })).toBeNull();
});
});
describe('loadStoredAccountSummary / setStoredAccountSummary', () => {
const summary: StoredAccountSummary = {
appleId: 'u@e.com',
teamId: 'T1',
teamName: 'Team One',
updatedAtIso: '2024-01-01T00:00:00.000Z',
};
it('returns null when nothing is stored', () => {
expect(loadStoredAccountSummary()).toBeNull();
});
it('round-trips a summary', () => {
setStoredAccountSummary(summary);
expect(loadStoredAccountSummary()).toEqual(summary);
});
it('returns null for malformed JSON', () => {
saveText(APPLE_ACCOUNT_SUMMARY_STORAGE_KEY, '{not json');
expect(loadStoredAccountSummary()).toBeNull();
});
it('returns null when required fields are missing', () => {
saveText(APPLE_ACCOUNT_SUMMARY_STORAGE_KEY, JSON.stringify({ appleId: 'x' }));
expect(loadStoredAccountSummary()).toBeNull();
});
});
describe('loadStoredAccountList', () => {
it('falls back to the single summary when no list is stored', () => {
setStoredAccountSummary({
appleId: 'solo@e.com',
teamId: 'T1',
teamName: 'Solo',
updatedAtIso: '2024-01-01T00:00:00.000Z',
});
expect(loadStoredAccountList()).toHaveLength(1);
});
it('returns [] when nothing is stored', () => {
expect(loadStoredAccountList()).toEqual([]);
});
it('filters out malformed entries from a stored list', () => {
saveText(
APPLE_ACCOUNT_LIST_STORAGE_KEY,
JSON.stringify([
{ appleId: 'ok@e.com', teamId: 'T1', teamName: 'n', updatedAtIso: '2024-01-01' },
{ appleId: '', teamId: 'bad' },
null,
]),
);
const list = loadStoredAccountList();
expect(list).toHaveLength(1);
expect(list[0].appleId).toBe('ok@e.com');
});
it('returns [] when list JSON is malformed', () => {
saveText(APPLE_ACCOUNT_LIST_STORAGE_KEY, 'garbage');
expect(loadStoredAccountList()).toEqual([]);
});
});
describe('persistAccountSummary', () => {
it('writes the summary and prepends it into the list, capping at 12 + dedup', () => {
persistAccountSummary(
makeContext({ appleId: 'a@e.com', team: { identifier: 'T1', name: 'One' } as AppleDeveloperContext['team'] }),
);
persistAccountSummary(
makeContext({ appleId: 'b@e.com', team: { identifier: 'T2', name: 'Two' } as AppleDeveloperContext['team'] }),
);
// Re-persist A — should move to the top, not duplicate.
persistAccountSummary(
makeContext({ appleId: 'a@e.com', team: { identifier: 'T1', name: 'One' } as AppleDeveloperContext['team'] }),
);
const list = loadStoredAccountList();
expect(list.map((entry) => entry.appleId)).toEqual(['a@e.com', 'b@e.com']);
expect(loadStoredAccountSummary()?.appleId).toBe('a@e.com');
});
});
describe('readAccountSessionMap / writeAccountSessionMap', () => {
it('returns {} when the key is missing', () => {
expect(readAccountSessionMap()).toEqual({});
});
it('skips entries that fail shape validation', () => {
saveText(
APPLE_ACCOUNT_SESSION_MAP_STORAGE_KEY,
JSON.stringify({
good: {
appleId: 'a@e.com',
teamId: 'T1',
teamName: 'One',
dsid: 'd',
authToken: 't',
anisetteData: encodeAnisetteData(sampleAnisette),
updatedAtIso: '2024-01-01',
},
bad: { appleId: 'only' },
}),
);
const map = readAccountSessionMap();
expect(Object.keys(map)).toEqual(['good']);
});
it('returns {} when the stored value is an array', () => {
saveText(APPLE_ACCOUNT_SESSION_MAP_STORAGE_KEY, '[]');
expect(readAccountSessionMap()).toEqual({});
});
it('returns {} for malformed JSON', () => {
saveText(APPLE_ACCOUNT_SESSION_MAP_STORAGE_KEY, 'not-json');
expect(readAccountSessionMap()).toEqual({});
});
it('round-trips via writeAccountSessionMap', () => {
const payload: StoredAccountSessionPayload = {
appleId: 'a@e.com',
teamId: 'T1',
teamName: 'One',
dsid: 'd',
authToken: 't',
anisetteData: encodeAnisetteData(sampleAnisette),
updatedAtIso: '2024-01-01',
};
writeAccountSessionMap({ key: payload });
expect(readAccountSessionMap()).toEqual({ key: payload });
});
});
describe('persistAccountSession / removeStoredAccountSession', () => {
it('persists under the canonical account key and removes on request', () => {
const ctx = makeContext();
persistAccountSession(ctx, sampleAnisette);
const map = readAccountSessionMap();
expect(Object.keys(map)).toHaveLength(1);
expect(Object.keys(map)[0]).toBe('user@example.com::TEAMA');
expect(removeStoredAccountSession(ctx.appleId, ctx.team.identifier)).toBe(true);
expect(readAccountSessionMap()).toEqual({});
// Idempotent: second removal is a no-op and returns false.
expect(removeStoredAccountSession(ctx.appleId, ctx.team.identifier)).toBe(false);
});
it('falls back to context.session.anisetteData when no anisette is passed', () => {
persistAccountSession(makeContext(), null);
const map = readAccountSessionMap();
const entry = map[Object.keys(map)[0]];
expect(entry.anisetteData.machineID).toBe(sampleAnisette.machineID);
});
});
describe('restorePersistedAccountContexts', () => {
it('rebuilds the in-memory context map from stored sessions', () => {
const payload: StoredAccountSessionPayload = {
appleId: 'a@e.com',
teamId: 'T1',
teamName: 'One',
dsid: 'd',
authToken: 't',
anisetteData: encodeAnisetteData(sampleAnisette),
updatedAtIso: '2024-01-01',
};
writeAccountSessionMap({ 'a@e.com::T1': payload });
const restored = restorePersistedAccountContexts();
expect(restored.size).toBe(1);
const ctx = restored.get('a@e.com::T1');
expect(ctx?.appleId).toBe('a@e.com');
expect(ctx?.team.identifier).toBe('T1');
expect(ctx?.session.dsid).toBe('d');
});
it('skips entries whose anisette payload fails to decode', () => {
const goodAnisette = encodeAnisetteData(sampleAnisette);
const badAnisette: StoredAnisetteDataPayload = { ...goodAnisette, machineID: '' };
writeAccountSessionMap({
'good::T1': {
appleId: 'good@e.com',
teamId: 'T1',
teamName: 'n',
dsid: 'd',
authToken: 't',
anisetteData: goodAnisette,
updatedAtIso: '2024-01-01',
},
'bad::T2': {
appleId: 'bad@e.com',
teamId: 'T2',
teamName: 'n',
dsid: 'd',
authToken: 't',
anisetteData: badAnisette,
updatedAtIso: '2024-01-01',
},
});
const restored = restorePersistedAccountContexts();
// Keys are rebuilt from payload.appleId / payload.teamId, not from the
// original map key.
expect(Array.from(restored.keys())).toEqual(['good@e.com::T1']);
});
});
describe('integration: summary + list + loadText all stay in sync', () => {
it('exposes via loadText the raw serialized values', () => {
persistAccountSummary(makeContext());
expect(loadText(APPLE_ACCOUNT_SUMMARY_STORAGE_KEY)).toContain('"appleId":"user@example.com"');
expect(loadText(APPLE_ACCOUNT_LIST_STORAGE_KEY)).toContain('TEAMA');
});
});

View File

@@ -0,0 +1,236 @@
import type { AnisetteData } from '../anisette-service';
import type { AppleDeveloperContext } from '../apple-signing';
import {
APPLE_ACCOUNT_LIST_STORAGE_KEY,
APPLE_ACCOUNT_SESSION_MAP_STORAGE_KEY,
APPLE_ACCOUNT_SUMMARY_STORAGE_KEY,
loadText,
writeJson,
} from './storage';
import { accountKey } from './ids';
export interface StoredAccountSummary {
appleId: string;
teamId: string;
teamName: string;
updatedAtIso: string;
}
export interface StoredAnisetteDataPayload {
machineID: string;
oneTimePassword: string;
localUserID: string;
routingInfo: number;
deviceUniqueIdentifier: string;
deviceDescription: string;
deviceSerialNumber: string;
dateIso: string;
locale: string;
timeZone: string;
}
export interface StoredAccountSessionPayload {
appleId: string;
teamId: string;
teamName: string;
dsid: string;
authToken: string;
anisetteData: StoredAnisetteDataPayload;
updatedAtIso: string;
}
export function encodeAnisetteData(data: AnisetteData): StoredAnisetteDataPayload {
return {
machineID: data.machineID,
oneTimePassword: data.oneTimePassword,
localUserID: data.localUserID,
routingInfo: data.routingInfo,
deviceUniqueIdentifier: data.deviceUniqueIdentifier,
deviceDescription: data.deviceDescription,
deviceSerialNumber: data.deviceSerialNumber,
dateIso: data.date.toISOString(),
locale: data.locale,
timeZone: data.timeZone,
};
}
export function decodeAnisetteData(payload: StoredAnisetteDataPayload): AnisetteData | null {
if (
!payload.machineID ||
!payload.oneTimePassword ||
!payload.localUserID ||
!payload.deviceUniqueIdentifier ||
!payload.deviceDescription ||
!payload.locale ||
!payload.timeZone
) {
return null;
}
if (!Number.isFinite(payload.routingInfo)) return null;
const date = new Date(payload.dateIso);
if (Number.isNaN(date.getTime())) return null;
return {
machineID: payload.machineID,
oneTimePassword: payload.oneTimePassword,
localUserID: payload.localUserID,
routingInfo: payload.routingInfo,
deviceUniqueIdentifier: payload.deviceUniqueIdentifier,
deviceDescription: payload.deviceDescription,
deviceSerialNumber: payload.deviceSerialNumber || '0',
date,
locale: payload.locale,
timeZone: payload.timeZone,
};
}
export 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;
}
}
export function setStoredAccountSummary(summary: StoredAccountSummary): void {
writeJson(APPLE_ACCOUNT_SUMMARY_STORAGE_KEY, summary);
}
export function loadStoredAccountList(): StoredAccountSummary[] {
const raw = loadText(APPLE_ACCOUNT_LIST_STORAGE_KEY);
if (!raw) {
const single = loadStoredAccountSummary();
return single ? [single] : [];
}
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
const normalized: StoredAccountSummary[] = [];
for (const item of parsed) {
if (!item || typeof item !== 'object') continue;
const candidate = item as StoredAccountSummary;
if (!candidate.appleId || !candidate.teamId || !candidate.teamName || !candidate.updatedAtIso) continue;
normalized.push(candidate);
}
return normalized;
} catch {
return [];
}
}
export function persistAccountSummary(context: AppleDeveloperContext): void {
const payload: StoredAccountSummary = {
appleId: context.appleId,
teamId: context.team.identifier,
teamName: context.team.name,
updatedAtIso: new Date().toISOString(),
};
setStoredAccountSummary(payload);
const existing = loadStoredAccountList();
const next = [
payload,
...existing.filter((item) => !(item.appleId === payload.appleId && item.teamId === payload.teamId)),
].slice(0, 12);
writeJson(APPLE_ACCOUNT_LIST_STORAGE_KEY, next);
}
export function readAccountSessionMap(): Record<string, StoredAccountSessionPayload> {
const raw = loadText(APPLE_ACCOUNT_SESSION_MAP_STORAGE_KEY);
if (!raw) return {};
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
const normalized: Record<string, StoredAccountSessionPayload> = {};
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
const candidate = value as Partial<StoredAccountSessionPayload>;
if (
typeof candidate.appleId !== 'string' ||
typeof candidate.teamId !== 'string' ||
typeof candidate.teamName !== 'string' ||
typeof candidate.dsid !== 'string' ||
typeof candidate.authToken !== 'string' ||
!candidate.anisetteData ||
typeof candidate.updatedAtIso !== 'string'
) {
continue;
}
normalized[key] = {
appleId: candidate.appleId,
teamId: candidate.teamId,
teamName: candidate.teamName,
dsid: candidate.dsid,
authToken: candidate.authToken,
anisetteData: candidate.anisetteData as StoredAnisetteDataPayload,
updatedAtIso: candidate.updatedAtIso,
};
}
return normalized;
} catch {
return {};
}
}
export function writeAccountSessionMap(map: Record<string, StoredAccountSessionPayload>): void {
writeJson(APPLE_ACCOUNT_SESSION_MAP_STORAGE_KEY, map);
}
export function persistAccountSession(context: AppleDeveloperContext, anisette: AnisetteData | null): void {
const map = readAccountSessionMap();
const key = accountKey(context.appleId, context.team.identifier);
const anisetteData = anisette ?? context.session.anisetteData;
map[key] = {
appleId: context.appleId,
teamId: context.team.identifier,
teamName: context.team.name,
dsid: context.session.dsid,
authToken: context.session.authToken,
anisetteData: encodeAnisetteData(anisetteData),
updatedAtIso: new Date().toISOString(),
};
writeAccountSessionMap(map);
}
export function removeStoredAccountSession(appleId: string, teamId: string): boolean {
const key = accountKey(appleId, teamId);
const map = readAccountSessionMap();
if (!(key in map)) return false;
delete map[key];
writeAccountSessionMap(map);
return true;
}
export function restorePersistedAccountContexts(): Map<string, AppleDeveloperContext> {
const result = new Map<string, AppleDeveloperContext>();
const sessionMap = readAccountSessionMap();
for (const payload of Object.values(sessionMap)) {
const anisette = decodeAnisetteData(payload.anisetteData);
if (!anisette) continue;
const restored: AppleDeveloperContext = {
appleId: payload.appleId,
session: {
anisetteData: anisette,
dsid: payload.dsid,
authToken: payload.authToken,
},
team: {
identifier: payload.teamId,
name: payload.teamName,
} as AppleDeveloperContext['team'],
certificates: [],
devices: [],
};
result.set(accountKey(payload.appleId, payload.teamId), restored);
}
return result;
}

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { accountKey, buildPreparedSourceKey, formatError, formatFileSize, normalizePem, shortToken } from './ids';
describe('accountKey', () => {
it('normalizes the apple id to lower-case and the team id to upper-case', () => {
expect(accountKey('User@Example.com', 'abc123')).toBe('user@example.com::ABC123');
});
it('trims surrounding whitespace', () => {
expect(accountKey(' a@b.com ', ' xyz ')).toBe('a@b.com::XYZ');
});
});
describe('buildPreparedSourceKey', () => {
it('combines name, size, lastModified and udid', () => {
const file = new File([new Uint8Array(10)], 'app.ipa', { lastModified: 12345 });
expect(buildPreparedSourceKey(file, 'UDID-1')).toBe('app.ipa:10:12345:UDID-1');
});
});
describe('shortToken', () => {
it('passes through short strings unchanged', () => {
expect(shortToken('abcdef')).toBe('abcdef');
expect(shortToken('abcdefghij')).toBe('abcdefghij');
});
it('truncates long strings with the 6..4 pattern', () => {
expect(shortToken('abcdef1234567890xyzt')).toBe('abcdef...xyzt');
});
it('trims before measuring length', () => {
expect(shortToken(' short ')).toBe('short');
});
});
describe('normalizePem', () => {
it('strips NULs and normalizes CRLF', () => {
expect(normalizePem('line1\r\nline2\0\r\n')).toBe('line1\nline2\n');
});
it('ensures a trailing newline', () => {
expect(normalizePem('no-newline')).toBe('no-newline\n');
});
});
describe('formatError', () => {
it('returns the message for an Error instance', () => {
expect(formatError(new Error('boom'))).toBe('boom');
});
it('stringifies non-Error values', () => {
expect(formatError(42)).toBe('42');
expect(formatError({ toString: () => 'obj' })).toBe('obj');
});
});
describe('formatFileSize', () => {
it('handles zero / negative as 0 B', () => {
expect(formatFileSize(0)).toBe('0 B');
expect(formatFileSize(-1)).toBe('0 B');
});
it('shows bytes below 1KiB', () => {
expect(formatFileSize(512)).toBe('512 B');
});
it('shows KB below 1MiB', () => {
expect(formatFileSize(2048)).toBe('2.0 KB');
});
it('shows MB at or above 1MiB', () => {
expect(formatFileSize(2 * 1024 * 1024)).toBe('2.00 MB');
});
});

30
frontend/src/lib/ids.ts Normal file
View File

@@ -0,0 +1,30 @@
export function accountKey(appleId: string, teamId: string): string {
return `${appleId.trim().toLowerCase()}::${teamId.trim().toUpperCase()}`;
}
export function buildPreparedSourceKey(file: File, udid: string): string {
return `${file.name}:${file.size}:${file.lastModified}:${udid}`;
}
export function shortToken(value: string): string {
const text = value.trim();
if (text.length <= 10) return text;
return `${text.slice(0, 6)}...${text.slice(-4)}`;
}
export function normalizePem(value: string): string {
const normalized = value.replace(/\0/g, '').replace(/\r\n/g, '\n').trim();
return `${normalized}\n`;
}
export function formatError(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}
export function formatFileSize(bytes: number): string {
if (bytes <= 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { parseInstallProgress, parseProgressFromLog, parseSigningProgress } from './log-parser';
describe('parseInstallProgress', () => {
it('returns null when the message lacks an InstProxy status', () => {
expect(parseInstallProgress('nothing to see', 0)).toBeNull();
});
it('parses an explicit percent', () => {
const out = parseInstallProgress('InstProxy status: Installing, Percent=42%', 0);
expect(out).toEqual({ source: 'install', percent: 42, status: 'Installing' });
});
it('reports 100% when status is complete', () => {
const out = parseInstallProgress('InstProxy status: Complete', 0);
expect(out).toEqual({ source: 'install', percent: 100, status: 'Complete' });
});
it('uses the provided last percent when no percent is in the message', () => {
const out = parseInstallProgress('InstProxy status: Preparing', 17);
expect(out).toEqual({ source: 'install', percent: 17, status: 'Preparing' });
});
});
describe('parseSigningProgress', () => {
it('maps known signing stage messages to percents', () => {
const cases: Array<[string, number]> = [
['sign: preparing ipa for upload', 8],
['signing stage: refreshing team', 14],
['signing stage: refreshed team info', 22],
['signing stage: using team T1', 28],
['signing stage: creating development certificate', 36],
['signing stage: using cached certificate', 40],
['signing stage: certificate ready', 48],
['signing stage: registering device', 56],
['signing stage: device already registered', 62],
['signing stage: device registered', 62],
['signing stage: device confirmed', 62],
['signing stage: creating app id', 72],
['signing stage: reuse app id XYZ', 72],
['signing stage: fetching provisioning profile', 82],
['signing stage: resigning ipa', 90],
['signing stage: complete', 100],
['sign: done -> foo.ipa', 100],
];
for (const [message, percent] of cases) {
const update = parseSigningProgress(message);
expect(update).not.toBeNull();
expect(update?.percent).toBe(percent);
expect(update?.source).toBe('sign');
}
});
it('returns null for unrelated messages', () => {
expect(parseSigningProgress('nothing relevant')).toBeNull();
});
});
describe('parseProgressFromLog', () => {
it('prefers install parser when both could match', () => {
// InstProxy wins via short-circuit, sign keyword is ignored.
const update = parseProgressFromLog('InstProxy status: Installing, Percent=55% signing stage: complete', 0);
expect(update?.source).toBe('install');
expect(update?.percent).toBe(55);
});
it('falls back to signing parser when install status is absent', () => {
const update = parseProgressFromLog('signing stage: resigning ipa', 0);
expect(update?.source).toBe('sign');
expect(update?.percent).toBe(90);
});
it('returns null when nothing matches', () => {
expect(parseProgressFromLog('whatever', 0)).toBeNull();
});
});

View File

@@ -0,0 +1,73 @@
export type ProgressSource = 'sign' | 'install';
export interface ProgressUpdate {
percent: number;
status: string;
source: ProgressSource;
}
export function parseInstallProgress(message: string, lastPercent: number): 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: lastPercent, status };
}
export function 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;
}
export function parseProgressFromLog(message: string, lastInstallPercent: number): ProgressUpdate | null {
return parseInstallProgress(message, lastInstallPercent) ?? parseSigningProgress(message);
}

View File

@@ -0,0 +1,199 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock the webmuxd barrel so no WASM / WebUSB plumbing is pulled into the
// test module graph. Only the primitives pair-record.ts uses are needed.
vi.mock('webmuxd', () => {
let hostCounter = 0;
let buidCounter = 0;
return {
createHostId: () => `host-${++hostCounter}`,
createSystemBuid: () => `buid-${++buidCounter}`,
encodeStoredPairRecord: (record: { hostId: string; systemBuid: string; devicePublicKey: Uint8Array }) => ({
hostId: record.hostId,
systemBuid: record.systemBuid,
hostCertificatePem: 'host-cert',
hostPrivateKeyPem: 'host-key',
rootCertificatePem: 'root-cert',
rootPrivateKeyPem: 'root-key',
deviceCertificatePem: 'device-cert',
devicePublicKey: btoa(String.fromCharCode(...Array.from(record.devicePublicKey))),
escrowBag: null,
}),
decodeStoredPairRecord: (payload: { hostId: string; systemBuid: string; devicePublicKey: string }) => ({
hostId: payload.hostId,
systemBuid: payload.systemBuid,
hostCertificatePem: 'host-cert',
hostPrivateKeyPem: 'host-key',
rootCertificatePem: 'root-cert',
rootPrivateKeyPem: 'root-key',
deviceCertificatePem: 'device-cert',
devicePublicKey: new Uint8Array(Array.from(atob(payload.devicePublicKey)).map((c) => c.charCodeAt(0))),
}),
generatePairRecordWithOpenSslWasm: async (req: {
devicePublicKey: Uint8Array;
hostId: string;
systemBuid: string;
}): Promise<string> =>
JSON.stringify({
hostId: req.hostId,
systemBuid: req.systemBuid,
hostCertificatePem: 'HCERT\0',
hostPrivateKeyPem: 'HKEY\0',
rootCertificatePem: 'RCERT\0',
rootPrivateKeyPem: 'RKEY\0',
deviceCertificatePem: 'DCERT\0',
}),
};
});
import {
createPairRecord,
getOrCreateHostId,
getOrCreateSystemBuid,
listKnownDeviceUdids,
loadPairRecordForUdid,
readPairRecordMap,
savePairRecordForUdid,
writePairRecordMap,
} from './pair-record';
import {
HOST_ID_STORAGE_KEY,
LEGACY_PAIR_RECORD_STORAGE_KEY,
PAIR_RECORDS_STORAGE_KEY,
SYSTEM_BUID_STORAGE_KEY,
loadText,
saveText,
} from './storage';
beforeEach(() => {
window.localStorage.clear();
});
describe('getOrCreateHostId / getOrCreateSystemBuid', () => {
it('creates and persists a fresh id when missing', () => {
const host = getOrCreateHostId();
expect(host).toMatch(/^host-\d+$/);
expect(loadText(HOST_ID_STORAGE_KEY)).toBe(host);
});
it('returns the existing id when already stored', () => {
saveText(HOST_ID_STORAGE_KEY, 'stored-host');
expect(getOrCreateHostId()).toBe('stored-host');
});
it('treats whitespace-only stored value as missing', () => {
saveText(HOST_ID_STORAGE_KEY, ' ');
const created = getOrCreateHostId();
expect(created).toMatch(/^host-\d+$/);
});
it('creates system buid when missing', () => {
const buid = getOrCreateSystemBuid();
expect(buid).toMatch(/^buid-\d+$/);
expect(loadText(SYSTEM_BUID_STORAGE_KEY)).toBe(buid);
});
});
describe('readPairRecordMap / writePairRecordMap', () => {
it('returns an empty object when no map is stored', () => {
expect(readPairRecordMap()).toEqual({});
});
it('returns an empty object for malformed JSON', () => {
saveText(PAIR_RECORDS_STORAGE_KEY, 'garbage');
expect(readPairRecordMap()).toEqual({});
});
it('returns an empty object when the stored value is an array', () => {
saveText(PAIR_RECORDS_STORAGE_KEY, '[]');
expect(readPairRecordMap()).toEqual({});
});
it('round-trips a map', () => {
writePairRecordMap({ 'UDID-A': { hostId: 'h', systemBuid: 'b' } as never });
expect(readPairRecordMap()).toEqual({ 'UDID-A': { hostId: 'h', systemBuid: 'b' } });
});
});
describe('savePairRecordForUdid / loadPairRecordForUdid', () => {
const record = {
hostId: 'host-x',
systemBuid: 'buid-x',
hostCertificatePem: 'host-cert',
hostPrivateKeyPem: 'host-key',
rootCertificatePem: 'root-cert',
rootPrivateKeyPem: 'root-key',
deviceCertificatePem: 'device-cert',
devicePublicKey: new Uint8Array([1, 2, 3, 4]),
};
it('ignores an empty udid', () => {
savePairRecordForUdid('', record);
expect(readPairRecordMap()).toEqual({});
});
it('stores and loads a record by udid', () => {
savePairRecordForUdid('UDID-1', record);
const loaded = loadPairRecordForUdid('UDID-1');
expect(loaded).not.toBeNull();
expect(loaded?.hostId).toBe('host-x');
expect(loaded?.devicePublicKey).toEqual(new Uint8Array([1, 2, 3, 4]));
});
it('returns null for unknown udid when legacy record missing', () => {
expect(loadPairRecordForUdid('missing')).toBeNull();
});
it('migrates a legacy single pair record under the requested udid', () => {
saveText(
LEGACY_PAIR_RECORD_STORAGE_KEY,
JSON.stringify({
hostId: 'legacy-host',
systemBuid: 'legacy-buid',
hostCertificatePem: '',
hostPrivateKeyPem: '',
rootCertificatePem: '',
rootPrivateKeyPem: '',
deviceCertificatePem: '',
devicePublicKey: btoa('\x01\x02'),
}),
);
const loaded = loadPairRecordForUdid('UDID-LEG');
expect(loaded?.hostId).toBe('legacy-host');
// Migration persists under the modern map and clears the legacy slot.
expect(readPairRecordMap()['UDID-LEG']).toBeDefined();
expect(loadText(LEGACY_PAIR_RECORD_STORAGE_KEY)).toBeNull();
});
});
describe('listKnownDeviceUdids', () => {
it('returns only stored UDIDs when no extra is passed', () => {
writePairRecordMap({
'UDID-B': {} as never,
'UDID-A': {} as never,
});
expect(listKnownDeviceUdids(null)).toEqual(['UDID-A', 'UDID-B']);
});
it('includes the extra UDID and deduplicates', () => {
writePairRecordMap({ 'UDID-A': {} as never });
expect(listKnownDeviceUdids('UDID-A')).toEqual(['UDID-A']);
expect(listKnownDeviceUdids('UDID-C')).toEqual(['UDID-A', 'UDID-C']);
});
});
describe('createPairRecord', () => {
it('calls the WASM generator and normalizes the returned PEM blocks', async () => {
const devicePubkey = new Uint8Array([0x10, 0x20]);
const record = await createPairRecord(devicePubkey, 'host-1', 'buid-1');
expect(record.hostId).toBe('host-1');
expect(record.systemBuid).toBe('buid-1');
// normalizePem strips NULs and ensures trailing newline.
expect(record.hostCertificatePem).toBe('HCERT\n');
expect(record.hostPrivateKeyPem).toBe('HKEY\n');
expect(record.deviceCertificatePem).toBe('DCERT\n');
expect(record.devicePublicKey).toEqual(devicePubkey);
// The returned buffer is a copy, not the input reference.
expect(record.devicePublicKey).not.toBe(devicePubkey);
});
});

View File

@@ -0,0 +1,135 @@
import {
createHostId,
createSystemBuid,
decodeStoredPairRecord,
encodeStoredPairRecord,
generatePairRecordWithOpenSslWasm,
type PairRecord,
type StoredPairRecordPayload,
} from 'webmuxd';
import {
HOST_ID_STORAGE_KEY,
LEGACY_PAIR_RECORD_STORAGE_KEY,
PAIR_RECORDS_STORAGE_KEY,
SYSTEM_BUID_STORAGE_KEY,
loadText,
removeText,
saveText,
writeJson,
} from './storage';
import { normalizePem } from './ids';
type WasmPairRecordPayload = Pick<
PairRecord,
| 'hostId'
| 'systemBuid'
| 'hostCertificatePem'
| 'hostPrivateKeyPem'
| 'rootCertificatePem'
| 'rootPrivateKeyPem'
| 'deviceCertificatePem'
>;
export function getOrCreateHostId(): string {
const existing = loadText(HOST_ID_STORAGE_KEY);
if (existing && existing.trim().length > 0) return existing;
const created = createHostId();
saveText(HOST_ID_STORAGE_KEY, created);
return created;
}
export function getOrCreateSystemBuid(): string {
const existing = loadText(SYSTEM_BUID_STORAGE_KEY);
if (existing && existing.trim().length > 0) return existing;
const created = createSystemBuid();
saveText(SYSTEM_BUID_STORAGE_KEY, created);
return created;
}
export function readPairRecordMap(): Record<string, StoredPairRecordPayload> {
const raw = loadText(PAIR_RECORDS_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 Record<string, StoredPairRecordPayload>;
} catch {
return {};
}
}
export function writePairRecordMap(map: Record<string, StoredPairRecordPayload>): void {
writeJson(PAIR_RECORDS_STORAGE_KEY, map);
}
export function savePairRecordForUdid(udid: string, record: PairRecord): void {
const normalizedUdid = udid.trim();
if (normalizedUdid.length === 0) return;
const map = readPairRecordMap();
map[normalizedUdid] = encodeStoredPairRecord(record);
writePairRecordMap(map);
}
function loadLegacyPairRecord(): PairRecord | null {
const raw = loadText(LEGACY_PAIR_RECORD_STORAGE_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as StoredPairRecordPayload;
return decodeStoredPairRecord(parsed);
} catch {
return null;
}
}
export 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 decodeStoredPairRecord(fromMap);
} catch {
return null;
}
}
const legacy = loadLegacyPairRecord();
if (legacy) {
savePairRecordForUdid(normalizedUdid, legacy);
removeText(LEGACY_PAIR_RECORD_STORAGE_KEY);
}
return legacy;
}
export async function createPairRecord(
devicePublicKeyBytes: Uint8Array,
hostId: string,
systemBuid: string,
): Promise<PairRecord> {
const payloadText = await generatePairRecordWithOpenSslWasm({
devicePublicKey: 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),
};
}
export function listKnownDeviceUdids(extra?: string | null): string[] {
const known = new Set(Object.keys(readPairRecordMap()));
if (extra && extra.trim().length > 0) known.add(extra);
return Array.from(known).sort((a, b) => a.localeCompare(b));
}

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import {
APPLE_ACCOUNT_LIST_STORAGE_KEY,
APPLE_ACCOUNT_SESSION_MAP_STORAGE_KEY,
APPLE_ACCOUNT_SUMMARY_STORAGE_KEY,
APPLE_ID_STORAGE_KEY,
APPLE_REMEMBER_SESSION_STORAGE_KEY,
HOST_ID_STORAGE_KEY,
LEGACY_PAIR_RECORD_STORAGE_KEY,
PAIR_RECORDS_STORAGE_KEY,
SELECTED_DEVICE_UDID_STORAGE_KEY,
SYSTEM_BUID_STORAGE_KEY,
loadText,
readJson,
removeText,
saveText,
writeJson,
} from './storage';
describe('storage keys', () => {
it('preserves the canonical webmuxd keys (existing users must not lose data)', () => {
expect(HOST_ID_STORAGE_KEY).toBe('webmuxd:host-id');
expect(SYSTEM_BUID_STORAGE_KEY).toBe('webmuxd:system-buid');
expect(PAIR_RECORDS_STORAGE_KEY).toBe('webmuxd:pair-records-by-udid');
expect(LEGACY_PAIR_RECORD_STORAGE_KEY).toBe('webmuxd:pair-record');
expect(APPLE_ID_STORAGE_KEY).toBe('webmuxd:apple-id');
expect(APPLE_ACCOUNT_SUMMARY_STORAGE_KEY).toBe('webmuxd:apple-account-summary');
expect(APPLE_ACCOUNT_LIST_STORAGE_KEY).toBe('webmuxd:apple-account-list');
expect(APPLE_ACCOUNT_SESSION_MAP_STORAGE_KEY).toBe('webmuxd:apple-account-session-map');
expect(APPLE_REMEMBER_SESSION_STORAGE_KEY).toBe('webmuxd:apple-remember-session');
expect(SELECTED_DEVICE_UDID_STORAGE_KEY).toBe('webmuxd:selected-device-udid');
});
});
describe('loadText / saveText / removeText', () => {
it('round-trips a string', () => {
saveText('k', 'v');
expect(loadText('k')).toBe('v');
});
it('returns null for a missing key', () => {
expect(loadText('missing')).toBeNull();
});
it('removes a key', () => {
saveText('k', 'v');
removeText('k');
expect(loadText('k')).toBeNull();
});
});
describe('readJson / writeJson', () => {
const isStringArray = (value: unknown): value is string[] =>
Array.isArray(value) && value.every((item) => typeof item === 'string');
it('round-trips a value that passes the guard', () => {
writeJson('arr', ['a', 'b']);
expect(readJson('arr', isStringArray)).toEqual(['a', 'b']);
});
it('returns null when the stored value fails the guard', () => {
saveText('arr', JSON.stringify({ not: 'array' }));
expect(readJson('arr', isStringArray)).toBeNull();
});
it('returns null for malformed JSON', () => {
saveText('arr', 'not-json');
expect(readJson('arr', isStringArray)).toBeNull();
});
it('returns null when the key is missing', () => {
expect(readJson('missing', isStringArray)).toBeNull();
});
});

View File

@@ -0,0 +1,37 @@
export const HOST_ID_STORAGE_KEY = 'webmuxd:host-id';
export const SYSTEM_BUID_STORAGE_KEY = 'webmuxd:system-buid';
export const PAIR_RECORDS_STORAGE_KEY = 'webmuxd:pair-records-by-udid';
export const LEGACY_PAIR_RECORD_STORAGE_KEY = 'webmuxd:pair-record';
export const APPLE_ID_STORAGE_KEY = 'webmuxd:apple-id';
export const APPLE_ACCOUNT_SUMMARY_STORAGE_KEY = 'webmuxd:apple-account-summary';
export const APPLE_ACCOUNT_LIST_STORAGE_KEY = 'webmuxd:apple-account-list';
export const APPLE_ACCOUNT_SESSION_MAP_STORAGE_KEY = 'webmuxd:apple-account-session-map';
export const APPLE_REMEMBER_SESSION_STORAGE_KEY = 'webmuxd:apple-remember-session';
export const SELECTED_DEVICE_UDID_STORAGE_KEY = 'webmuxd:selected-device-udid';
export function loadText(key: string): string | null {
return window.localStorage.getItem(key);
}
export function saveText(key: string, value: string): void {
window.localStorage.setItem(key, value);
}
export function removeText(key: string): void {
window.localStorage.removeItem(key);
}
export function readJson<T>(key: string, guard: (value: unknown) => value is T): T | null {
const raw = loadText(key);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
return guard(parsed) ? parsed : null;
} catch {
return null;
}
}
export function writeJson(key: string, value: unknown): void {
saveText(key, JSON.stringify(value));
}

View File

@@ -0,0 +1,40 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useLog } from './use-log';
describe('useLog', () => {
it('starts empty', () => {
const { result } = renderHook(() => useLog());
expect(result.current.lines).toEqual([]);
});
it('appends timestamped lines', () => {
const { result } = renderHook(() => useLog());
act(() => {
result.current.addLog('hello');
});
expect(result.current.lines).toHaveLength(1);
expect(result.current.lines[0]).toMatch(/\] hello$/);
});
it('caps at 200 lines (oldest dropped)', () => {
const { result } = renderHook(() => useLog());
act(() => {
for (let i = 0; i < 250; i++) {
result.current.addLog(`m${i}`);
}
});
expect(result.current.lines).toHaveLength(200);
expect(result.current.lines[0]).toMatch(/m50$/);
expect(result.current.lines[199]).toMatch(/m249$/);
});
it('clearLog empties the buffer', () => {
const { result } = renderHook(() => useLog());
act(() => {
result.current.addLog('x');
result.current.clearLog();
});
expect(result.current.lines).toEqual([]);
});
});

View File

@@ -0,0 +1,30 @@
import { useCallback, useRef, useState } from 'react';
const MAX_LINES = 200;
export interface UseLogResult {
lines: string[];
addLog: (message: string) => void;
clearLog: () => void;
}
export function useLog(): UseLogResult {
const [lines, setLines] = useState<string[]>([]);
const bufferRef = useRef<string[]>([]);
const addLog = useCallback((message: string) => {
const now = new Date();
const time = `${now.toLocaleTimeString()}.${String(now.getMilliseconds()).padStart(3, '0')}`;
const line = `[${time}] ${message}`;
console.log(`%c[sideload]%c ${message}`, 'color:#2563eb;font-weight:600', 'color:inherit');
bufferRef.current = [...bufferRef.current, line].slice(-MAX_LINES);
setLines(bufferRef.current);
}, []);
const clearLog = useCallback(() => {
bufferRef.current = [];
setLines([]);
}, []);
return { lines, addLog, clearLog };
}

File diff suppressed because it is too large Load Diff

15
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './style.css';
import { App } from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('App root is missing');
}
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -1,4 +1,24 @@
@import "tailwindcss";
@import 'tailwindcss';
@theme {
--color-bg: #ffffff;
--color-surface: #fafafa;
--color-elevated: #ffffff;
--color-border: rgba(10, 10, 10, 0.08);
--color-border-strong: rgba(10, 10, 10, 0.14);
--color-ink: #0a0a0a;
--color-muted: #6b7280;
--color-subtle: #9ca3af;
--color-accent: #2563eb;
--color-accent-soft: #eff6ff;
--color-success: #16a34a;
--color-success-soft: #f0fdf4;
--color-danger: #dc2626;
--color-danger-soft: #fef2f2;
--font-mono: 'SF Mono', 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Helvetica, Arial, system-ui, sans-serif;
}
:root {
color-scheme: light;
@@ -8,71 +28,518 @@
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
color: #0f172a;
font-family: "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif;
background-color: var(--color-bg);
color: var(--color-ink);
font-family: var(--font-sans);
font-feature-settings: 'ss01', 'cv11';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body.modal-open {
overflow: hidden;
}
.page-tab[data-active="true"] {
border-color: #2563eb;
background-color: #2563eb;
::selection {
background-color: rgba(37, 99, 235, 0.18);
}
/* ----- form primitives ----- */
.field-input {
display: block;
width: 100%;
height: 44px;
padding: 0 14px;
border-radius: 10px;
border: 1px solid var(--color-border);
background: var(--color-bg);
color: var(--color-ink);
font: inherit;
font-size: 14px;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
}
.field-input::placeholder {
color: var(--color-subtle);
}
.field-input:hover:not(:focus):not(:disabled) {
border-color: var(--color-border-strong);
}
.field-input:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18);
}
.field-input:disabled {
background: var(--color-surface);
color: var(--color-muted);
cursor: not-allowed;
}
.field-select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none' stroke='%236b7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
/* ----- buttons ----- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 44px;
padding: 0 18px;
border-radius: 10px;
border: 1px solid transparent;
background: transparent;
color: var(--color-ink);
font: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
user-select: none;
white-space: nowrap;
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, opacity 0.15s ease,
transform 0.05s ease;
}
.btn:active:not(:disabled) {
transform: scale(0.985);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.38;
}
.btn-primary {
background: var(--color-ink);
color: #ffffff;
}
.page-tab[data-active="false"] {
border-color: #d1d5db;
background-color: #ffffff;
color: #374151;
.btn-primary:hover:not(:disabled) {
background: #1f1f1f;
}
.page-tab[data-active="false"]:hover {
background-color: #f9fafb;
.btn-accent {
background: var(--color-accent);
color: #ffffff;
}
.setup-step {
border-color: #e5e7eb;
background-color: #f9fafb;
color: #6b7280;
.btn-accent:hover:not(:disabled) {
background: #1d4ed8;
}
.setup-step[data-state="active"] {
border-color: #93c5fd;
background-color: #eff6ff;
color: #1d4ed8;
.btn-ghost {
border-color: var(--color-border);
background: var(--color-bg);
color: var(--color-ink);
}
.setup-step[data-state="done"] {
border-color: #86efac;
background-color: #f0fdf4;
color: #15803d;
.btn-ghost:hover:not(:disabled) {
border-color: var(--color-border-strong);
background: var(--color-surface);
}
.btn-sm {
height: 32px;
padding: 0 12px;
font-size: 12.5px;
border-radius: 8px;
}
/* ----- segmented nav ----- */
.seg {
display: inline-flex;
padding: 3px;
border-radius: 10px;
background: var(--color-surface);
border: 1px solid var(--color-border);
}
.seg-btn {
height: 32px;
padding: 0 14px;
border-radius: 7px;
border: 0;
background: transparent;
font: inherit;
font-size: 13px;
font-weight: 500;
color: var(--color-muted);
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
}
.seg-btn:hover {
color: var(--color-ink);
}
.seg-btn[data-active='true'] {
background: var(--color-bg);
color: var(--color-ink);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
/* ----- card ----- */
.card {
border: 1px solid var(--color-border);
border-radius: 14px;
background: var(--color-elevated);
}
.card-pad {
padding: 22px;
}
@media (min-width: 640px) {
.card-pad {
padding: 26px;
}
}
/* ----- stepper (Cookey-style horizontal) ----- */
.stepper {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
}
.stepper-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
min-width: 72px;
}
.stepper-marker {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
min-width: 32px;
padding: 0 12px;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-bg);
color: var(--color-muted);
font-family: var(--font-mono);
font-size: 12px;
font-weight: 500;
transition: background-color 0.25s ease, color 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
}
.stepper-step[data-state='active'] .stepper-marker {
background: var(--color-ink);
color: #ffffff;
border-color: var(--color-ink);
box-shadow: 0 0 0 4px rgba(10, 10, 10, 0.06);
}
.stepper-step[data-state='active'] .stepper-marker::after {
content: '';
position: absolute;
inset: -6px;
border-radius: 999px;
border: 1px solid rgba(10, 10, 10, 0.18);
animation: pulse-ring 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
pointer-events: none;
}
.stepper-step[data-state='done'] .stepper-marker {
background: var(--color-success-soft);
color: var(--color-success);
border-color: rgba(22, 163, 74, 0.28);
}
.stepper-label {
font-size: 12px;
font-weight: 500;
color: var(--color-muted);
letter-spacing: 0.01em;
}
.stepper-step[data-state='active'] .stepper-label {
color: var(--color-ink);
}
.stepper-step[data-state='done'] .stepper-label {
color: var(--color-success);
}
.stepper-bar {
flex: 0 0 36px;
height: 1px;
background: var(--color-border);
margin: 0 4px;
margin-bottom: 26px;
transition: background-color 0.3s ease;
}
@media (min-width: 480px) {
.stepper-bar {
flex-basis: 56px;
}
}
.stepper-bar[data-done='true'] {
background: rgba(22, 163, 74, 0.4);
}
@keyframes pulse-ring {
0%,
100% {
opacity: 0.6;
transform: scale(1);
}
50% {
opacity: 0;
transform: scale(1.18);
}
}
/* ----- drop zone ----- */
.drop-zone {
display: block;
cursor: pointer;
padding: 26px 18px;
text-align: center;
border: 1.5px dashed var(--color-border-strong);
border-radius: 12px;
background: var(--color-surface);
transition: border-color 0.18s ease, background-color 0.18s ease, transform 0.18s ease;
}
.drop-zone.dragover {
border-color: #3b82f6;
background-color: #eff6ff;
transform: translateY(-2px);
.drop-zone:hover {
border-color: var(--color-accent);
background: var(--color-accent-soft);
}
.trust-modal.open {
display: flex;
.drop-zone.dragover {
border-color: var(--color-accent);
background: var(--color-accent-soft);
transform: scale(1.01);
}
/* ----- progress bar ----- */
.progress-track {
position: relative;
height: 6px;
border-radius: 999px;
background: var(--color-surface);
overflow: hidden;
border: 1px solid var(--color-border);
}
.progress-fill {
position: absolute;
inset: 0;
width: 0%;
background: var(--color-ink);
border-radius: 999px;
transition: width 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}
.progress-fill[data-busy='true']::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.45), transparent);
animation: shimmer 1.4s linear infinite;
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
/* ----- log ----- */
pre.log {
margin: 0;
padding: 14px 16px;
border-radius: 10px;
background: #0a0a0a;
color: #e5e7eb;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.7;
max-height: 280px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
pre.log::-webkit-scrollbar {
width: 10px;
width: 8px;
height: 8px;
}
pre.log::-webkit-scrollbar-thumb {
border: 2px solid #f9fafb;
background: rgba(255, 255, 255, 0.18);
border-radius: 999px;
background: #9ca3af;
}
pre.log::-webkit-scrollbar-track {
background: transparent;
}
/* ----- modal ----- */
.modal {
position: fixed;
inset: 0;
z-index: 60;
display: none;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(10, 10, 10, 0.32);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
opacity: 0;
transition: opacity 0.18s ease;
}
.modal.open {
display: flex;
opacity: 1;
}
.modal-panel {
width: 100%;
max-width: 420px;
border-radius: 16px;
border: 1px solid var(--color-border);
background: var(--color-bg);
padding: 22px;
box-shadow: 0 20px 50px -20px rgba(10, 10, 10, 0.25), 0 4px 12px -4px rgba(10, 10, 10, 0.12);
transform: translateY(8px) scale(0.98);
opacity: 0;
transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.18s ease;
}
.modal.open .modal-panel {
transform: translateY(0) scale(1);
opacity: 1;
}
/* ----- pill / chip ----- */
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 22px;
padding: 0 10px;
border-radius: 999px;
background: var(--color-surface);
border: 1px solid var(--color-border);
font-size: 11.5px;
font-weight: 500;
color: var(--color-muted);
letter-spacing: 0.01em;
}
.chip[data-tone='success'] {
background: var(--color-success-soft);
border-color: rgba(22, 163, 74, 0.22);
color: var(--color-success);
}
.chip[data-tone='accent'] {
background: var(--color-accent-soft);
border-color: rgba(37, 99, 235, 0.22);
color: var(--color-accent);
}
/* ----- account row ----- */
.acct-row {
position: relative;
padding: 12px 14px 12px 18px;
border-radius: 10px;
border: 1px solid transparent;
transition: background-color 0.15s ease;
}
.acct-row:hover {
background: var(--color-surface);
}
.acct-row[data-active='true'] {
background: var(--color-surface);
}
.acct-row[data-active='true']::before {
content: '';
position: absolute;
left: 6px;
top: 14px;
bottom: 14px;
width: 2px;
border-radius: 2px;
background: var(--color-ink);
}
/* ----- spinner ----- */
.spinner {
width: 14px;
height: 14px;
border-radius: 999px;
border: 1.5px solid currentColor;
border-top-color: transparent;
animation: spin 0.7s linear infinite;
display: inline-block;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ----- entrance animations ----- */
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.anim-in {
animation: fade-in-up 0.32s cubic-bezier(0.22, 1, 0.36, 1) both;
}

View File

@@ -0,0 +1,83 @@
/**
* Test that the libcurl init wrapper produces clear error messages when the
* WASM module or WISP backend is unavailable.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Reset module state between tests — initLibcurl caches its promise.
let initLibcurl: typeof import('../anisette-libcurl-init').initLibcurl;
beforeEach(async () => {
vi.resetModules();
});
describe('initLibcurl error handling', () => {
it('wraps loadLibcurl failure with a descriptive message', async () => {
vi.doMock('../wasm/libcurl', () => ({
loadLibcurl: async () => {
throw new Error('module not found');
},
libcurl: undefined,
}));
const mod = await import('../anisette-libcurl-init');
initLibcurl = mod.initLibcurl;
await expect(initLibcurl()).rejects.toThrow(/Failed to load libcurl WASM module/);
await expect(initLibcurl()).rejects.toThrow(/module not found/);
});
it('wraps load_wasm failure with a WISP backend hint', async () => {
vi.doMock('../wasm/libcurl', () => ({
loadLibcurl: async () => ({
set_websocket: () => {},
load_wasm: async () => {
throw new Error('ECONNREFUSED');
},
}),
libcurl: undefined,
}));
const mod = await import('../anisette-libcurl-init');
initLibcurl = mod.initLibcurl;
await expect(initLibcurl()).rejects.toThrow(/WISP backend running/);
await expect(initLibcurl()).rejects.toThrow(/ECONNREFUSED/);
});
it('resets the promise after failure so retries are possible', async () => {
let callCount = 0;
vi.doMock('../wasm/libcurl', () => ({
loadLibcurl: async () => {
callCount++;
if (callCount === 1) throw new Error('transient');
return {
set_websocket: () => {},
load_wasm: async () => {},
};
},
libcurl: undefined,
}));
const mod = await import('../anisette-libcurl-init');
initLibcurl = mod.initLibcurl;
// First call fails.
await expect(initLibcurl()).rejects.toThrow(/transient/);
// Second call succeeds because the promise was reset.
await expect(initLibcurl()).resolves.toBeUndefined();
});
it('succeeds when libcurl loads and wasm initializes', async () => {
vi.doMock('../wasm/libcurl', () => ({
loadLibcurl: async () => ({
set_websocket: () => {},
load_wasm: async () => {},
}),
libcurl: undefined,
}));
const mod = await import('../anisette-libcurl-init');
initLibcurl = mod.initLibcurl;
await expect(initLibcurl()).resolves.toBeUndefined();
// Subsequent calls are idempotent.
await expect(initLibcurl()).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,81 @@
/**
* Integration test: verify the patched libcurl.js can load and reach Apple's
* GSA endpoint through the WISP backend.
*
* Requires `wrangler dev --port 8787` running. Skipped automatically if the
* backend is unreachable.
*/
import { describe, expect, it, beforeAll } from 'vitest';
import { access } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
describe('libcurl.js Apple connectivity', () => {
it('binary/libcurl_full.mjs exists in the submodule', async () => {
await expect(access(resolve(root, 'wasm/libcurl-wasm/binary/libcurl_full.mjs'))).resolves.toBeUndefined();
});
it('binary/libcurl.mjs exists in the submodule', async () => {
await expect(access(resolve(root, 'wasm/libcurl-wasm/binary/libcurl.mjs'))).resolves.toBeUndefined();
});
it('binary/libcurl.wasm exists in the submodule', async () => {
await expect(access(resolve(root, 'wasm/libcurl-wasm/binary/libcurl.wasm'))).resolves.toBeUndefined();
});
it('dist/bundled.mjs exists and re-exports from binary', async () => {
const { readFile } = await import('node:fs/promises');
const content = await readFile(resolve(root, 'wasm/libcurl-wasm/dist/bundled.mjs'), 'utf-8');
expect(content).toContain('../binary/libcurl_full.mjs');
});
it('frontend/public/anisette/libcurl_full.mjs matches the submodule binary', async () => {
const { readFile } = await import('node:fs/promises');
const [submodule, publicCopy] = await Promise.all([
readFile(resolve(root, 'wasm/libcurl-wasm/binary/libcurl_full.mjs')),
readFile(resolve(root, 'frontend/public/anisette/libcurl_full.mjs')),
]);
expect(submodule.equals(publicCopy)).toBe(true);
});
describe('WISP backend connectivity (requires wrangler dev)', () => {
let backendAvailable = false;
beforeAll(async () => {
try {
const resp = await fetch('http://localhost:8787/healthz', {
signal: AbortSignal.timeout(2000),
});
backendAvailable = resp.ok;
} catch {
backendAvailable = false;
}
});
it('WISP backend healthcheck returns ok', () => {
if (!backendAvailable) {
console.log(' [skipped] wrangler dev not running on :8787');
return;
}
expect(backendAvailable).toBe(true);
});
it('WISP endpoint accepts WebSocket upgrade', async () => {
if (!backendAvailable) {
console.log(' [skipped] wrangler dev not running on :8787');
return;
}
// Just verify the upgrade endpoint exists — actual WS connection
// requires the libcurl WASM runtime which only works in a browser.
const resp = await fetch('http://localhost:8787/wisp/', {
signal: AbortSignal.timeout(2000),
});
// Without Upgrade header, should get 426
expect(resp.status).toBe(426);
const body = (await resp.json()) as { error?: string };
expect(body.error).toContain('WebSocket upgrade required');
});
});
});

View File

@@ -0,0 +1,55 @@
import '@testing-library/jest-dom/vitest';
import { afterEach, beforeEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
// Node 22+ ships an experimental `localStorage` global that lacks a working
// `clear()` (it requires a `--localstorage-file=` flag to be fully wired).
// Happy-dom's Window.localStorage is shadowed by that stub. Replace it with a
// plain in-memory implementation so tests are deterministic.
class MemoryStorage implements Storage {
private readonly map = new Map<string, string>();
get length(): number {
return this.map.size;
}
key(index: number): string | null {
return Array.from(this.map.keys())[index] ?? null;
}
getItem(key: string): string | null {
return this.map.has(key) ? this.map.get(key) ?? null : null;
}
setItem(key: string, value: string): void {
this.map.set(key, String(value));
}
removeItem(key: string): void {
this.map.delete(key);
}
clear(): void {
this.map.clear();
}
}
function installStorage(name: 'localStorage' | 'sessionStorage'): void {
const storage = new MemoryStorage();
Object.defineProperty(globalThis, name, { configurable: true, value: storage });
Object.defineProperty(window, name, { configurable: true, value: storage });
}
installStorage('localStorage');
installStorage('sessionStorage');
beforeEach(() => {
window.localStorage.clear();
window.sessionStorage.clear();
window.location.hash = '';
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

View File

@@ -0,0 +1,69 @@
/**
* Verify that all WASM dist bundles and their binary dependencies exist on
* disk. These are built by `bun run build:wasm:dist`.
*
* We test file existence (not dynamic import) because the modules pull in
* heavy WASM blobs and browser-only APIs that don't load in a test env.
*/
import { describe, expect, it } from 'vitest';
import { access, realpath } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
async function fileExists(relativePath: string): Promise<boolean> {
try {
await access(resolve(root, relativePath));
return true;
} catch {
return false;
}
}
async function symlinkResolves(relativePath: string): Promise<boolean> {
try {
await realpath(resolve(root, relativePath));
return true;
} catch {
return false;
}
}
describe('WASM dist artifacts', () => {
it('openssl dist/index.mjs exists', async () => {
expect(await fileExists('wasm/openssl/dist/index.mjs')).toBe(true);
});
it('openssl binary/openssl_wasm.js exists (via symlink to pkg)', async () => {
expect(await symlinkResolves('wasm/openssl/binary/openssl_wasm.js')).toBe(true);
});
it('openssl binary/openssl_wasm_bg.wasm exists', async () => {
expect(await symlinkResolves('wasm/openssl/binary/openssl_wasm_bg.wasm')).toBe(true);
});
it('libcurl dist/bundled.mjs exists', async () => {
expect(await fileExists('wasm/libcurl-wasm/dist/bundled.mjs')).toBe(true);
});
it('libcurl dist/index.mjs exists', async () => {
expect(await fileExists('wasm/libcurl-wasm/dist/index.mjs')).toBe(true);
});
it('libcurl binary/libcurl_full.mjs resolves', async () => {
expect(await symlinkResolves('wasm/libcurl-wasm/binary/libcurl_full.mjs')).toBe(true);
});
it('libcurl binary/libcurl.mjs resolves', async () => {
expect(await symlinkResolves('wasm/libcurl-wasm/binary/libcurl.mjs')).toBe(true);
});
it('zsign js/dist/index.mjs exists', async () => {
expect(await fileExists('wasm/zsign-wasm/js/dist/index.mjs')).toBe(true);
});
it('zsign js/dist/browser.mjs exists', async () => {
expect(await fileExists('wasm/zsign-wasm/js/dist/browser.mjs')).toBe(true);
});
});

View File

@@ -1,6 +1,6 @@
export const libcurl: {
fetch(input: string | URL, init?: Record<string, unknown>): Promise<Response>
load_wasm(url?: string): Promise<void>
set_websocket(url: string): void
readonly ready?: boolean
}
fetch(input: string | URL, init?: Record<string, unknown>): Promise<Response>;
load_wasm(url?: string): Promise<void>;
set_websocket(url: string): void;
readonly ready?: boolean;
};

View File

@@ -1,53 +1,53 @@
export interface LibcurlApi {
fetch(input: string | URL, init?: Record<string, unknown>): Promise<Response>
load_wasm(url?: string): Promise<void>
set_websocket(url: string): void
readonly ready?: boolean
fetch(input: string | URL, init?: Record<string, unknown>): Promise<Response>;
load_wasm(url?: string): Promise<void>;
set_websocket(url: string): void;
readonly ready?: boolean;
}
let libcurlModulePromise: Promise<LibcurlApi> | null = null
export let libcurl: LibcurlApi
let libcurlModulePromise: Promise<LibcurlApi> | null = null;
export let libcurl: LibcurlApi;
const toLibcurl = (moduleValue: unknown): LibcurlApi => {
if (!moduleValue || typeof moduleValue !== "object") {
throw new Error("libcurl module did not return an object")
if (!moduleValue || typeof moduleValue !== 'object') {
throw new Error('libcurl module did not return an object');
}
const candidate = moduleValue as Record<string, unknown>
if (!candidate.libcurl || typeof candidate.libcurl !== "object") {
throw new Error("libcurl module is missing the libcurl export")
const candidate = moduleValue as Record<string, unknown>;
if (!candidate.libcurl || typeof candidate.libcurl !== 'object') {
throw new Error('libcurl module is missing the libcurl export');
}
const loadedLibcurl = candidate.libcurl as Partial<LibcurlApi>
if (typeof loadedLibcurl.fetch !== "function") {
throw new Error("libcurl export is missing fetch")
const loadedLibcurl = candidate.libcurl as Partial<LibcurlApi>;
if (typeof loadedLibcurl.fetch !== 'function') {
throw new Error('libcurl export is missing fetch');
}
if (typeof loadedLibcurl.load_wasm !== "function") {
throw new Error("libcurl export is missing load_wasm")
if (typeof loadedLibcurl.load_wasm !== 'function') {
throw new Error('libcurl export is missing load_wasm');
}
if (typeof loadedLibcurl.set_websocket !== "function") {
throw new Error("libcurl export is missing set_websocket")
if (typeof loadedLibcurl.set_websocket !== 'function') {
throw new Error('libcurl export is missing set_websocket');
}
return loadedLibcurl as LibcurlApi
}
return loadedLibcurl as LibcurlApi;
};
export const loadLibcurl = async (): Promise<LibcurlApi> => {
if (!libcurlModulePromise) {
libcurlModulePromise = import("./libcurl-entry.js").then((moduleValue) => {
const loadedLibcurl = toLibcurl(moduleValue)
libcurl = loadedLibcurl
return loadedLibcurl
})
libcurlModulePromise = import('./libcurl-entry.js').then((moduleValue) => {
const loadedLibcurl = toLibcurl(moduleValue);
libcurl = loadedLibcurl;
return loadedLibcurl;
});
}
return await libcurlModulePromise
}
return await libcurlModulePromise;
};
export const requireLibcurl = (): LibcurlApi => {
if (!libcurl) {
throw new Error("libcurl is not ready. Call initLibcurl() first.")
throw new Error('libcurl is not ready. Call initLibcurl() first.');
}
return libcurl
}
return libcurl;
};

View File

@@ -1,120 +1,123 @@
declare module "webmuxd" {
declare module 'webmuxd' {
export 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
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;
}
export interface PairRecord {
hostId: string
systemBuid: string
hostCertificatePem: string
hostPrivateKeyPem: string
rootCertificatePem: string
rootPrivateKeyPem: string
deviceCertificatePem: string
devicePublicKey: Uint8Array
escrowBag?: Uint8Array
hostId: string;
systemBuid: string;
hostCertificatePem: string;
hostPrivateKeyPem: string;
rootCertificatePem: string;
rootPrivateKeyPem: string;
deviceCertificatePem: string;
devicePublicKey: Uint8Array;
escrowBag?: Uint8Array;
}
export interface StoredPairRecordPayload {
hostId: string
systemBuid: string
hostCertificatePem: string
hostPrivateKeyPem: string
rootCertificatePem: string
rootPrivateKeyPem: string
deviceCertificatePem: string
devicePublicKey: string
escrowBag: string | null
hostId: string;
systemBuid: string;
hostCertificatePem: string;
hostPrivateKeyPem: string;
rootCertificatePem: string;
rootPrivateKeyPem: string;
deviceCertificatePem: string;
devicePublicKey: string;
escrowBag: string | null;
}
export interface TlsConnection {
is_handshaking(): boolean
write_plaintext(data: Uint8Array): void
feed_tls(data: Uint8Array): void
take_tls_out(): Uint8Array
take_plain_out(): Uint8Array
free(): void
is_handshaking(): boolean;
write_plaintext(data: Uint8Array): void;
feed_tls(data: Uint8Array): void;
take_tls_out(): Uint8Array;
take_plain_out(): Uint8Array;
free(): void;
}
export interface TlsConnectionFactory {
ensureReady?(): Promise<void>
ensureReady?(): Promise<void>;
createConnection(request: {
serverName: string
caCertificatePem: string
certificatePem: string
privateKeyPem: string
}): TlsConnection
serverName: string;
caCertificatePem: string;
certificatePem: string;
privateKeyPem: string;
}): TlsConnection;
}
export class WebUsbTransport implements WebUsbTransportInstance {
constructor(device: unknown, options?: { logger?: unknown; transferSize?: number })
readonly isOpen: boolean
static supported(): boolean
static requestAppleDevice(logger?: unknown): Promise<WebUsbTransport>
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
constructor(device: unknown, options?: { logger?: unknown; transferSize?: number });
readonly isOpen: boolean;
static supported(): boolean;
static requestAppleDevice(logger?: unknown): Promise<WebUsbTransport>;
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;
}
export class DirectUsbMuxClient {
constructor(
transport: WebUsbTransportInstance,
options?: {
log?: (message: string) => void
onStateChange?: () => void
lockdownLabel?: string
tlsFactory?: TlsConnectionFactory
log?: (message: string) => void;
onStateChange?: () => void;
lockdownLabel?: string;
tlsFactory?: TlsConnectionFactory;
pairRecordFactory?: {
createPairRecord(request: {
devicePublicKey: Uint8Array
hostId: string
systemBuid: string
}): Promise<PairRecord>
}
devicePublicKey: Uint8Array;
hostId: string;
systemBuid: string;
}): Promise<PairRecord>;
};
},
)
readonly isHandshakeComplete: boolean
readonly isLockdownConnected: 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<{
sessionId: string
enableSessionSsl: boolean
}>
close(): Promise<void>
);
readonly isHandshakeComplete: boolean;
readonly isLockdownConnected: 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<{
sessionId: string;
enableSessionSsl: boolean;
}>;
close(): Promise<void>;
}
export const LOCKDOWN_PORT: number
export const LOCKDOWN_PORT: number;
export function installIpaViaInstProxy(
client: DirectUsbMuxClient,
ipaData: Uint8Array,
fileName: string,
onLog?: (message: string) => void,
): Promise<void>
export function sanitizeIpaFileName(fileName: string): string
export function createHostId(): string
export function createSystemBuid(): string
export function encodeStoredPairRecord(record: PairRecord): StoredPairRecordPayload
export function decodeStoredPairRecord(parsed: StoredPairRecordPayload): PairRecord | null
export function createOpenSslWasmTlsFactory(): TlsConnectionFactory
): Promise<void>;
export function sanitizeIpaFileName(fileName: string): string;
export function createHostId(): string;
export function createSystemBuid(): string;
export function encodeStoredPairRecord(record: PairRecord): StoredPairRecordPayload;
export function decodeStoredPairRecord(parsed: StoredPairRecordPayload): PairRecord | null;
export function createOpenSslWasmTlsFactory(): TlsConnectionFactory;
export function generatePairRecordWithOpenSslWasm(request: {
devicePublicKey: Uint8Array
hostId: string
systemBuid: string
}): Promise<string>
devicePublicKey: Uint8Array;
hostId: string;
systemBuid: string;
}): Promise<string>;
}

View File

@@ -7,6 +7,8 @@
"types": ["vite/client"],
"skipLibCheck": true,
"jsx": "react-jsx",
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,

View File

@@ -1,34 +1,36 @@
import { defineConfig } from "vite"
import tailwindcss from "@tailwindcss/vite"
import { dirname, resolve } from "node:path"
import { fileURLToPath } from "node:url"
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const frontendDir = dirname(fileURLToPath(import.meta.url))
const repoRootDir = resolve(frontendDir, "..")
const frontendDir = dirname(fileURLToPath(import.meta.url));
const repoRootDir = resolve(frontendDir, '..');
export default defineConfig({
plugins: [tailwindcss()],
plugins: [react(), tailwindcss()],
server: {
fs: {
allow: [repoRootDir],
},
proxy: {
"/wisp": { target: "ws://localhost:8787", ws: true },
'/wisp': { target: 'ws://localhost:8787', ws: true },
},
},
resolve: {
alias: {
webmuxd: resolve(frontendDir, "src/webmuxd-browser.js"),
webmuxd: resolve(frontendDir, 'src/webmuxd-browser.js'),
},
},
optimizeDeps: {
include: ['jszip', 'node-forge', 'fflate'],
exclude: [
"altsign.js",
"@lbr77/anisette-js",
"@lbr77/anisette-js/browser",
"@lbr77/zsign-wasm-resigner-wrapper",
"libcurl.js",
"libcurl.js/bundled",
'altsign.js',
'@lbr77/anisette-js',
'@lbr77/anisette-js/browser',
'@lbr77/zsign-wasm-resigner-wrapper',
'libcurl.js',
'libcurl.js/bundled',
],
},
build: {
@@ -36,4 +38,4 @@ export default defineConfig({
include: [/node_modules/],
},
},
})
});

31
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,31 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const frontendDir = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
webmuxd: resolve(frontendDir, 'src/webmuxd-browser.js'),
},
},
test: {
environment: 'happy-dom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
css: false,
restoreMocks: true,
clearMocks: true,
server: {
deps: {
// The anisette / apple-signing / wasm modules pull huge native/WASM
// code. Tests mock them; excluding here prevents Vite from crawling.
inline: [],
},
},
},
});