mirror of
https://github.com/lbr77/SideImpactor.git
synced 2026-05-06 11:14:01 -04:00
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:
61
.dockerignore
Normal file
61
.dockerignore
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# VCS / editor
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.gitignore
|
||||||
|
.gitmodules
|
||||||
|
.prettierrc
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Markdown / docs
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
LICENSE
|
||||||
|
|
||||||
|
# Node / TS caches
|
||||||
|
**/node_modules
|
||||||
|
frontend/dist
|
||||||
|
**/build
|
||||||
|
**/out
|
||||||
|
**/.cache
|
||||||
|
**/.turbo
|
||||||
|
**/.vite
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
**/*.log
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Backend sources not needed for frontend build, but package.json is needed
|
||||||
|
# for workspace resolution during bun install.
|
||||||
|
backend/src
|
||||||
|
backend/worker-configuration.d.ts
|
||||||
|
backend/wrangler.jsonc
|
||||||
|
|
||||||
|
# WASM SOURCES — we only need the pre-built dist/binary outputs.
|
||||||
|
# Source checkouts include large vendored OpenSSL copies, Rust target dirs, etc.
|
||||||
|
# WASM native build artifacts (Rust/Emscripten) — only src/dist/binary/pkg needed
|
||||||
|
wasm/openssl/vendor
|
||||||
|
wasm/openssl/target
|
||||||
|
wasm/openssl/precompiled
|
||||||
|
wasm/libcurl-wasm/client/build
|
||||||
|
wasm/libcurl-wasm/client/out
|
||||||
|
wasm/libcurl-wasm/client/fragments
|
||||||
|
wasm/libcurl-wasm/server
|
||||||
|
wasm/zsign-wasm/native
|
||||||
|
wasm/zsign-wasm/build
|
||||||
|
wasm/zsign-wasm/test
|
||||||
|
|
||||||
|
# Tests / scripts not needed at runtime
|
||||||
|
dependencies/webmuxd/src/__tests__
|
||||||
|
scripts
|
||||||
|
|
||||||
|
# Docker meta
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Frontend public anisette libs are shipped via Vite public/ handling —
|
||||||
|
# keep them, but strip other platform natives if present.
|
||||||
39
.gitignore
vendored
39
.gitignore
vendored
@@ -1,3 +1,40 @@
|
|||||||
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
lib/
|
.pnp/
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
out/
|
||||||
|
*.tsbuildinfo
|
||||||
|
# webmuxd package compiled output
|
||||||
|
dependencies/webmuxd/lib/
|
||||||
|
|
||||||
|
# Caches
|
||||||
|
.cache/
|
||||||
|
.turbo/
|
||||||
|
.vite/
|
||||||
|
.parcel-cache/
|
||||||
|
|
||||||
|
# Editor / IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
!.vscode/extensions.json
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Logs + env
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
coverage/
|
||||||
|
.wrangler/
|
||||||
|
|||||||
26
AGENTS.md
26
AGENTS.md
@@ -1,30 +1,36 @@
|
|||||||
# AGENTS Guide
|
# AGENTS Guide
|
||||||
|
|
||||||
## Communication
|
|
||||||
- Reply to user in Chinese.
|
|
||||||
- Keep source code, identifiers, and comments in English.
|
|
||||||
|
|
||||||
## Package Manager
|
## Package Manager
|
||||||
|
|
||||||
- Use `bun` for all Node.js dependency and script operations.
|
- Use `bun` for all Node.js dependency and script operations.
|
||||||
|
|
||||||
## Project Layout
|
## Project Layout
|
||||||
|
|
||||||
- Core npm package source: `dependencies/webmuxd/src/`
|
- Core npm package source: `dependencies/webmuxd/src/`
|
||||||
- High-level iMobileDevice interactions: `dependencies/webmuxd/src/core/imobiledevice-client.ts`
|
- High-level iMobileDevice interactions: `dependencies/webmuxd/src/core/imobiledevice-client.ts`
|
||||||
- Browser demo app: `frontend/`
|
- Frontend React app: `frontend/src/`
|
||||||
- Cloudflare Workers demo backend: `backend/`
|
- Frontend entry: `frontend/src/main.tsx` → `App.tsx`
|
||||||
- OpenSSL Rust/WASM project: `wasm/openssl/`
|
- Frontend components: `frontend/src/components/`
|
||||||
|
- Frontend business logic: `frontend/src/lib/` (storage, pair-record, account-session) + `frontend/src/flows/` (pair, login, sign, install)
|
||||||
|
- Cloudflare Workers backend: `backend/`
|
||||||
|
- WASM packages: `wasm/openssl/`, `wasm/libcurl-wasm/`, `wasm/zsign-wasm/`
|
||||||
|
|
||||||
## Key Rule: Avoid Logic Duplication
|
## Key Rule: Avoid Logic Duplication
|
||||||
|
|
||||||
- Do not re-implement usbmux/lockdown/AFC/InstProxy protocol logic in `frontend`.
|
- Do not re-implement usbmux/lockdown/AFC/InstProxy protocol logic in `frontend`.
|
||||||
- `frontend/src/main.ts` must consume workspace package exports from `webmuxd`.
|
- Frontend must consume workspace package exports from `webmuxd` via the vite alias.
|
||||||
- If behavior changes are needed, modify `dependencies/webmuxd/` first, then wire it in frontend.
|
- If behavior changes are needed, modify `dependencies/webmuxd/` first, then wire it in frontend.
|
||||||
|
|
||||||
## Build & Validate
|
## Build & Validate
|
||||||
- Root build: `bun run build`
|
|
||||||
|
- WASM dist (always run before frontend): `bun run build:wasm:dist`
|
||||||
|
- Dev server: `bun run dev`
|
||||||
|
- Frontend build: `bun run build:frontend`
|
||||||
- Root lint: `bun run lint`
|
- Root lint: `bun run lint`
|
||||||
- Root test: `bun run test`
|
- Root test: `bun run test`
|
||||||
- Frontend build: `cd frontend && bun run build`
|
- Frontend tests: `bun run test:frontend`
|
||||||
|
|
||||||
## Change Style
|
## Change Style
|
||||||
|
|
||||||
- Keep changes minimal, focused, and consistent with existing style.
|
- Keep changes minimal, focused, and consistent with existing style.
|
||||||
- Prefer removing dead code over keeping legacy paths.
|
- Prefer removing dead code over keeping legacy paths.
|
||||||
|
|||||||
29
Dockerfile
Normal file
29
Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Multi-stage Docker build for the sideload.js frontend.
|
||||||
|
|
||||||
|
# --- Stage 1: build ---
|
||||||
|
FROM oven/bun:1.3-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy manifests first for dependency layer caching.
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
COPY frontend/package.json frontend/
|
||||||
|
COPY backend/package.json backend/
|
||||||
|
COPY dependencies/webmuxd/package.json dependencies/webmuxd/
|
||||||
|
COPY wasm/openssl/package.json wasm/openssl/
|
||||||
|
COPY wasm/libcurl-wasm/package.json wasm/libcurl-wasm/
|
||||||
|
COPY wasm/zsign-wasm/package.json wasm/zsign-wasm/
|
||||||
|
COPY wasm/zsign-wasm/js/package.json wasm/zsign-wasm/js/
|
||||||
|
|
||||||
|
RUN bun install --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
|
# Copy all sources (filtered by .dockerignore).
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build WASM dist bundles (src→dist copies) then frontend.
|
||||||
|
RUN bun run build:wasm:dist && cd frontend && bun run build
|
||||||
|
|
||||||
|
# --- Stage 2: serve ---
|
||||||
|
FROM nginx:1.29-alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/frontend/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 3000
|
||||||
58
README.md
58
README.md
@@ -1,13 +1,59 @@
|
|||||||
# Sideload.js
|
# Sideload.js
|
||||||
|
|
||||||
A pure frontend signing infrastructure.
|
Browser-based IPA signing and installation. Pair an iOS device over WebUSB, sign with your Apple Developer account, and install — all from a single web page.
|
||||||
|
|
||||||
## Install
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install --ignore-scripts
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
<!-- TODO: deploy to cf flow -->
|
Open `http://localhost:5173`.
|
||||||
Deploy to Cloudflare worker.
|
|
||||||
|
|
||||||
## Technology
|
## Project Structure
|
||||||
|
|
||||||
1. when signing
|
| Path | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `frontend/` | React + Tailwind SPA (Vite) |
|
||||||
|
| `backend/` | Cloudflare Workers relay |
|
||||||
|
| `dependencies/webmuxd/` | WebUSB usbmux/lockdown protocol library |
|
||||||
|
| `wasm/openssl/` | OpenSSL WASM (TLS + pair record generation) |
|
||||||
|
| `wasm/libcurl-wasm/` | libcurl WASM (Apple API HTTP via WISP proxy) |
|
||||||
|
| `wasm/zsign-wasm/` | zsign WASM (IPA re-signing) |
|
||||||
|
| `scripts/` | WASM native build scripts (Rust + Emscripten) |
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# WASM dist bundles (copies pre-built src→dist, no compiler needed)
|
||||||
|
bun run build:wasm:dist
|
||||||
|
|
||||||
|
# Frontend production build (runs wasm:dist automatically)
|
||||||
|
bun run build:frontend
|
||||||
|
|
||||||
|
# Full WASM recompile from source (requires Rust, Emscripten, precompiled OpenSSL)
|
||||||
|
bun run build:wasm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build:wasm:dist # ensure WASM dists exist
|
||||||
|
docker build -t sideload-web .
|
||||||
|
docker run -p 3000:3000 sideload-web
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run test # webmuxd unit tests
|
||||||
|
bun run test:frontend # frontend vitest suite (141 tests)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
- [libimobiledevice](https://github.com/libimobiledevice/libimobiledevice)
|
||||||
|
- [webmuxd](https://github.com/hack-different/webmuxd)
|
||||||
|
- [zsign](https://github.com/nicehash/zsign)
|
||||||
|
- [openssl-wasm](https://github.com/nicehash/openssl-wasm)
|
||||||
|
|||||||
29
README.zh.md
29
README.zh.md
@@ -1,29 +0,0 @@
|
|||||||
# Sideload.js
|
|
||||||
|
|
||||||
纯前端ipa签名工具
|
|
||||||
|
|
||||||
## 安装/部署
|
|
||||||
|
|
||||||
|
|
||||||
<!-- TODO: deploy to cf flow -->
|
|
||||||
Deploy to Cloudflare worker.
|
|
||||||
|
|
||||||
|
|
||||||
本地部署(With Dockerfile)
|
|
||||||
|
|
||||||
|
|
||||||
## 功能
|
|
||||||
|
|
||||||
1. apple客户端模拟(by unicorn over wasm)
|
|
||||||
2. Team,证书管理(获取,删除,注册)
|
|
||||||
3. zsign wasm(ipa解包,签名)
|
|
||||||
4. usbmuxd over webusb + lockdownd SSL service握手
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 感谢
|
|
||||||
|
|
||||||
1. libimobiledevice
|
|
||||||
2. https://github.com/hack-different/webmuxd
|
|
||||||
3. zsign
|
|
||||||
4. openssl-wasm
|
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>AltStore Web</title>
|
<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 & Install</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -7,7 +7,10 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lbr77/anisette-js": "0.1.3",
|
"@lbr77/anisette-js": "0.1.3",
|
||||||
@@ -17,13 +20,23 @@
|
|||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"libcurl.js": "workspace:*",
|
"libcurl.js": "workspace:*",
|
||||||
"node-forge": "^1.3.3",
|
"node-forge": "^1.3.3",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
"webmuxd": "workspace:*"
|
"webmuxd": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@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",
|
"tailwindcss": "^4.2.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
},
|
},
|
||||||
"packageManager": "bun@1.3.11"
|
"packageManager": "bun@1.3.11"
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
231
frontend/src/App.test.tsx
Normal file
231
frontend/src/App.test.tsx
Normal 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
612
frontend/src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,42 +1,42 @@
|
|||||||
import type { HttpClient } from "@lbr77/anisette-js"
|
import type { HttpClient } from '@lbr77/anisette-js';
|
||||||
import { initLibcurl } from "./anisette-libcurl-init"
|
import { initLibcurl } from './anisette-libcurl-init';
|
||||||
import { requireLibcurl } from "./wasm/libcurl"
|
import { requireLibcurl } from './wasm/libcurl';
|
||||||
|
|
||||||
export class LibcurlHttpClient implements HttpClient {
|
export class LibcurlHttpClient implements HttpClient {
|
||||||
async get(url: string, headers: Record<string, string>): Promise<Uint8Array> {
|
async get(url: string, headers: Record<string, string>): Promise<Uint8Array> {
|
||||||
await initLibcurl()
|
await initLibcurl();
|
||||||
const libcurl = requireLibcurl()
|
const libcurl = requireLibcurl();
|
||||||
|
|
||||||
const response = await libcurl.fetch(url, {
|
const response = await libcurl.fetch(url, {
|
||||||
method: "GET",
|
method: 'GET',
|
||||||
headers,
|
headers,
|
||||||
insecure: true,
|
insecure: true,
|
||||||
_libcurl_http_version: 1.1,
|
_libcurl_http_version: 1.1,
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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> {
|
async post(url: string, body: string, headers: Record<string, string>): Promise<Uint8Array> {
|
||||||
await initLibcurl()
|
await initLibcurl();
|
||||||
const libcurl = requireLibcurl()
|
const libcurl = requireLibcurl();
|
||||||
|
|
||||||
const response = await libcurl.fetch(url, {
|
const response = await libcurl.fetch(url, {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
body,
|
body,
|
||||||
headers,
|
headers,
|
||||||
insecure: true,
|
insecure: true,
|
||||||
_libcurl_http_version: 1.1,
|
_libcurl_http_version: 1.1,
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,41 @@
|
|||||||
import { loadLibcurl, libcurl } from "./wasm/libcurl"
|
import { loadLibcurl, libcurl } from './wasm/libcurl';
|
||||||
|
|
||||||
let initialized = false
|
let initialized = false;
|
||||||
let initPromise: Promise<void> | null = null
|
let initPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
export async function initLibcurl(): Promise<void> {
|
export async function initLibcurl(): Promise<void> {
|
||||||
if (initialized) {
|
if (initialized) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (initPromise) {
|
if (initPromise) {
|
||||||
return initPromise
|
return initPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
initPromise = (async () => {
|
initPromise = (async () => {
|
||||||
const loadedLibcurl = await loadLibcurl()
|
let loadedLibcurl;
|
||||||
const wsProto = location.protocol === "https:" ? "wss:" : "ws:"
|
try {
|
||||||
const wsUrl = `${wsProto}//${location.host}/wisp/`
|
loadedLibcurl = await loadLibcurl();
|
||||||
loadedLibcurl.set_websocket(wsUrl)
|
} catch (error) {
|
||||||
await loadedLibcurl.load_wasm()
|
initPromise = null;
|
||||||
initialized = true
|
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 };
|
||||||
|
|||||||
@@ -1,76 +1,80 @@
|
|||||||
import { Anisette, loadWasmModule } from "@lbr77/anisette-js"
|
import { Anisette, loadWasmModule } from '@lbr77/anisette-js';
|
||||||
import { initLibcurl } from "./anisette-libcurl-init"
|
import { initLibcurl } from './anisette-libcurl-init';
|
||||||
import { LibcurlHttpClient } from "./anisette-libcurl-http"
|
import { LibcurlHttpClient } from './anisette-libcurl-http';
|
||||||
|
|
||||||
export interface AnisetteData {
|
export interface AnisetteData {
|
||||||
machineID: string
|
machineID: string;
|
||||||
oneTimePassword: string
|
oneTimePassword: string;
|
||||||
localUserID: string
|
localUserID: string;
|
||||||
routingInfo: number
|
routingInfo: number;
|
||||||
deviceUniqueIdentifier: string
|
deviceUniqueIdentifier: string;
|
||||||
deviceDescription: string
|
deviceDescription: string;
|
||||||
deviceSerialNumber: string
|
deviceSerialNumber: string;
|
||||||
date: Date
|
date: Date;
|
||||||
locale: string
|
locale: string;
|
||||||
timeZone: string
|
timeZone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let anisetteInstance: Anisette | null = null
|
let anisetteInstance: Anisette | null = null;
|
||||||
|
|
||||||
export async function initAnisette(): Promise<Anisette> {
|
export async function initAnisette(): Promise<Anisette> {
|
||||||
if (anisetteInstance) {
|
if (anisetteInstance) {
|
||||||
return anisetteInstance
|
return anisetteInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
await initLibcurl()
|
await initLibcurl();
|
||||||
const httpClient = new LibcurlHttpClient()
|
const httpClient = new LibcurlHttpClient();
|
||||||
|
|
||||||
const wasmModule = await loadWasmModule()
|
const wasmModule = await loadWasmModule();
|
||||||
const [storeservicescore, coreadi] = await Promise.all([
|
const [storeservicescore, coreadi] = await Promise.all([
|
||||||
fetch("/anisette/libstoreservicescore.so").then((response) => response.arrayBuffer()).then((arr) => new Uint8Array(arr)),
|
fetch('/anisette/libstoreservicescore.so')
|
||||||
fetch("/anisette/libCoreADI.so").then((response) => response.arrayBuffer()).then((arr) => new Uint8Array(arr)),
|
.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, {
|
anisetteInstance = await Anisette.fromSo(storeservicescore, coreadi, wasmModule, {
|
||||||
httpClient,
|
httpClient,
|
||||||
init: {
|
init: {
|
||||||
libraryPath: "./anisette/",
|
libraryPath: './anisette/',
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return anisetteInstance
|
return anisetteInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function provisionAnisette(): Promise<void> {
|
export async function provisionAnisette(): Promise<void> {
|
||||||
const anisette = await initAnisette()
|
const anisette = await initAnisette();
|
||||||
if (!anisette.isProvisioned) {
|
if (!anisette.isProvisioned) {
|
||||||
await anisette.provision()
|
await anisette.provision();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAnisetteData(): Promise<AnisetteData> {
|
export async function getAnisetteData(): Promise<AnisetteData> {
|
||||||
const anisette = await initAnisette()
|
const anisette = await initAnisette();
|
||||||
|
|
||||||
if (!anisette.isProvisioned) {
|
if (!anisette.isProvisioned) {
|
||||||
await anisette.provision()
|
await anisette.provision();
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = await anisette.getData()
|
const headers = await anisette.getData();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
machineID: headers["X-Apple-I-MD-M"],
|
machineID: headers['X-Apple-I-MD-M'],
|
||||||
oneTimePassword: headers["X-Apple-I-MD"],
|
oneTimePassword: headers['X-Apple-I-MD'],
|
||||||
localUserID: headers["X-Apple-I-MD-LU"],
|
localUserID: headers['X-Apple-I-MD-LU'],
|
||||||
routingInfo: Number.parseInt(headers["X-Apple-I-MD-RINFO"], 10),
|
routingInfo: Number.parseInt(headers['X-Apple-I-MD-RINFO'], 10),
|
||||||
deviceUniqueIdentifier: headers["X-Mme-Device-Id"],
|
deviceUniqueIdentifier: headers['X-Mme-Device-Id'],
|
||||||
deviceDescription: headers["X-MMe-Client-Info"],
|
deviceDescription: headers['X-MMe-Client-Info'],
|
||||||
deviceSerialNumber: headers["X-Apple-I-SRL-NO"] || "0",
|
deviceSerialNumber: headers['X-Apple-I-SRL-NO'] || '0',
|
||||||
date: new Date(headers["X-Apple-I-Client-Time"]),
|
date: new Date(headers['X-Apple-I-Client-Time']),
|
||||||
locale: headers["X-Apple-Locale"],
|
locale: headers['X-Apple-Locale'],
|
||||||
timeZone: headers["X-Apple-I-TimeZone"],
|
timeZone: headers['X-Apple-I-TimeZone'],
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearAnisetteCache(): void {
|
export function clearAnisetteCache(): void {
|
||||||
anisetteInstance = null
|
anisetteInstance = null;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
87
frontend/src/components/DevicePicker.test.tsx
Normal file
87
frontend/src/components/DevicePicker.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
52
frontend/src/components/DevicePicker.tsx
Normal file
52
frontend/src/components/DevicePicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
frontend/src/components/DropZone.test.tsx
Normal file
59
frontend/src/components/DropZone.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
88
frontend/src/components/DropZone.tsx
Normal file
88
frontend/src/components/DropZone.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
frontend/src/components/Header.test.tsx
Normal file
32
frontend/src/components/Header.test.tsx
Normal 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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
46
frontend/src/components/Header.tsx
Normal file
46
frontend/src/components/Header.tsx
Normal 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 & Install
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
frontend/src/components/LoginModal.tsx
Normal file
106
frontend/src/components/LoginModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
frontend/src/components/LoginPage.test.tsx
Normal file
71
frontend/src/components/LoginPage.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
55
frontend/src/components/LoginPage.tsx
Normal file
55
frontend/src/components/LoginPage.tsx
Normal 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 & Install →
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
frontend/src/components/ProgressCard.test.tsx
Normal file
43
frontend/src/components/ProgressCard.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
113
frontend/src/components/ProgressCard.tsx
Normal file
113
frontend/src/components/ProgressCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
frontend/src/components/SavedAccountsList.test.tsx
Normal file
84
frontend/src/components/SavedAccountsList.test.tsx
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
79
frontend/src/components/SavedAccountsList.tsx
Normal file
79
frontend/src/components/SavedAccountsList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
frontend/src/components/SignPage.test.tsx
Normal file
68
frontend/src/components/SignPage.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
121
frontend/src/components/SignPage.tsx
Normal file
121
frontend/src/components/SignPage.tsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
frontend/src/components/Stepper.test.tsx
Normal file
49
frontend/src/components/Stepper.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
45
frontend/src/components/Stepper.tsx
Normal file
45
frontend/src/components/Stepper.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
frontend/src/components/TrustModal.test.tsx
Normal file
26
frontend/src/components/TrustModal.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
60
frontend/src/components/TrustModal.tsx
Normal file
60
frontend/src/components/TrustModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
frontend/src/components/TwoFactorModal.test.tsx
Normal file
58
frontend/src/components/TwoFactorModal.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
82
frontend/src/components/TwoFactorModal.tsx
Normal file
82
frontend/src/components/TwoFactorModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
frontend/src/components/ui/Button.test.tsx
Normal file
70
frontend/src/components/ui/Button.test.tsx
Normal 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…');
|
||||||
|
});
|
||||||
|
});
|
||||||
45
frontend/src/components/ui/Button.tsx
Normal file
45
frontend/src/components/ui/Button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
frontend/src/components/ui/Chip.test.tsx
Normal file
23
frontend/src/components/ui/Chip.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
12
frontend/src/components/ui/Chip.tsx
Normal file
12
frontend/src/components/ui/Chip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
frontend/src/components/ui/Field.test.tsx
Normal file
36
frontend/src/components/ui/Field.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
42
frontend/src/components/ui/Field.tsx
Normal file
42
frontend/src/components/ui/Field.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
87
frontend/src/components/ui/Modal.test.tsx
Normal file
87
frontend/src/components/ui/Modal.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
46
frontend/src/components/ui/Modal.tsx
Normal file
46
frontend/src/components/ui/Modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/src/flows/install.ts
Normal file
15
frontend/src/flows/install.ts
Normal 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');
|
||||||
|
}
|
||||||
77
frontend/src/flows/login.ts
Normal file
77
frontend/src/flows/login.ts
Normal 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);
|
||||||
|
}
|
||||||
99
frontend/src/flows/pair.ts
Normal file
99
frontend/src/flows/pair.ts
Normal 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 };
|
||||||
|
}
|
||||||
43
frontend/src/flows/sign.ts
Normal file
43
frontend/src/flows/sign.ts
Normal 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 };
|
||||||
|
}
|
||||||
309
frontend/src/lib/account-session.test.ts
Normal file
309
frontend/src/lib/account-session.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
236
frontend/src/lib/account-session.ts
Normal file
236
frontend/src/lib/account-session.ts
Normal 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;
|
||||||
|
}
|
||||||
74
frontend/src/lib/ids.test.ts
Normal file
74
frontend/src/lib/ids.test.ts
Normal 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
30
frontend/src/lib/ids.ts
Normal 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`;
|
||||||
|
}
|
||||||
76
frontend/src/lib/log-parser.test.ts
Normal file
76
frontend/src/lib/log-parser.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
73
frontend/src/lib/log-parser.ts
Normal file
73
frontend/src/lib/log-parser.ts
Normal 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);
|
||||||
|
}
|
||||||
199
frontend/src/lib/pair-record.test.ts
Normal file
199
frontend/src/lib/pair-record.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
135
frontend/src/lib/pair-record.ts
Normal file
135
frontend/src/lib/pair-record.ts
Normal 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));
|
||||||
|
}
|
||||||
74
frontend/src/lib/storage.test.ts
Normal file
74
frontend/src/lib/storage.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
37
frontend/src/lib/storage.ts
Normal file
37
frontend/src/lib/storage.ts
Normal 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));
|
||||||
|
}
|
||||||
40
frontend/src/lib/use-log.test.ts
Normal file
40
frontend/src/lib/use-log.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
30
frontend/src/lib/use-log.ts
Normal file
30
frontend/src/lib/use-log.ts
Normal 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 };
|
||||||
|
}
|
||||||
1638
frontend/src/main.ts
1638
frontend/src/main.ts
File diff suppressed because it is too large
Load Diff
15
frontend/src/main.tsx
Normal file
15
frontend/src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
@@ -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 {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
@@ -8,71 +28,518 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
color: #0f172a;
|
background-color: var(--color-bg);
|
||||||
font-family: "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
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 {
|
body.modal-open {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-tab[data-active="true"] {
|
::selection {
|
||||||
border-color: #2563eb;
|
background-color: rgba(37, 99, 235, 0.18);
|
||||||
background-color: #2563eb;
|
}
|
||||||
|
|
||||||
|
/* ----- 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;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-tab[data-active="false"] {
|
.btn-primary:hover:not(:disabled) {
|
||||||
border-color: #d1d5db;
|
background: #1f1f1f;
|
||||||
background-color: #ffffff;
|
|
||||||
color: #374151;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-tab[data-active="false"]:hover {
|
.btn-accent {
|
||||||
background-color: #f9fafb;
|
background: var(--color-accent);
|
||||||
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setup-step {
|
.btn-accent:hover:not(:disabled) {
|
||||||
border-color: #e5e7eb;
|
background: #1d4ed8;
|
||||||
background-color: #f9fafb;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.setup-step[data-state="active"] {
|
.btn-ghost {
|
||||||
border-color: #93c5fd;
|
border-color: var(--color-border);
|
||||||
background-color: #eff6ff;
|
background: var(--color-bg);
|
||||||
color: #1d4ed8;
|
color: var(--color-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.setup-step[data-state="done"] {
|
.btn-ghost:hover:not(:disabled) {
|
||||||
border-color: #86efac;
|
border-color: var(--color-border-strong);
|
||||||
background-color: #f0fdf4;
|
background: var(--color-surface);
|
||||||
color: #15803d;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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 {
|
.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;
|
transition: border-color 0.18s ease, background-color 0.18s ease, transform 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-zone.dragover {
|
.drop-zone:hover {
|
||||||
border-color: #3b82f6;
|
border-color: var(--color-accent);
|
||||||
background-color: #eff6ff;
|
background: var(--color-accent-soft);
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.trust-modal.open {
|
.drop-zone.dragover {
|
||||||
display: flex;
|
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 {
|
pre.log::-webkit-scrollbar {
|
||||||
width: 10px;
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
pre.log::-webkit-scrollbar-thumb {
|
pre.log::-webkit-scrollbar-thumb {
|
||||||
border: 2px solid #f9fafb;
|
background: rgba(255, 255, 255, 0.18);
|
||||||
border-radius: 999px;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
83
frontend/src/test/anisette-init.test.ts
Normal file
83
frontend/src/test/anisette-init.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
81
frontend/src/test/libcurl-apple.test.ts
Normal file
81
frontend/src/test/libcurl-apple.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
55
frontend/src/test/setup.ts
Normal file
55
frontend/src/test/setup.ts
Normal 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();
|
||||||
|
});
|
||||||
69
frontend/src/test/wasm-dist.test.ts
Normal file
69
frontend/src/test/wasm-dist.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
10
frontend/src/wasm/libcurl-entry.d.ts
vendored
10
frontend/src/wasm/libcurl-entry.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
export const libcurl: {
|
export const libcurl: {
|
||||||
fetch(input: string | URL, init?: Record<string, unknown>): Promise<Response>
|
fetch(input: string | URL, init?: Record<string, unknown>): Promise<Response>;
|
||||||
load_wasm(url?: string): Promise<void>
|
load_wasm(url?: string): Promise<void>;
|
||||||
set_websocket(url: string): void
|
set_websocket(url: string): void;
|
||||||
readonly ready?: boolean
|
readonly ready?: boolean;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,53 +1,53 @@
|
|||||||
export interface LibcurlApi {
|
export interface LibcurlApi {
|
||||||
fetch(input: string | URL, init?: Record<string, unknown>): Promise<Response>
|
fetch(input: string | URL, init?: Record<string, unknown>): Promise<Response>;
|
||||||
load_wasm(url?: string): Promise<void>
|
load_wasm(url?: string): Promise<void>;
|
||||||
set_websocket(url: string): void
|
set_websocket(url: string): void;
|
||||||
readonly ready?: boolean
|
readonly ready?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let libcurlModulePromise: Promise<LibcurlApi> | null = null
|
let libcurlModulePromise: Promise<LibcurlApi> | null = null;
|
||||||
export let libcurl: LibcurlApi
|
export let libcurl: LibcurlApi;
|
||||||
|
|
||||||
const toLibcurl = (moduleValue: unknown): LibcurlApi => {
|
const toLibcurl = (moduleValue: unknown): LibcurlApi => {
|
||||||
if (!moduleValue || typeof moduleValue !== "object") {
|
if (!moduleValue || typeof moduleValue !== 'object') {
|
||||||
throw new Error("libcurl module did not return an object")
|
throw new Error('libcurl module did not return an object');
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidate = moduleValue as Record<string, unknown>
|
const candidate = moduleValue as Record<string, unknown>;
|
||||||
if (!candidate.libcurl || typeof candidate.libcurl !== "object") {
|
if (!candidate.libcurl || typeof candidate.libcurl !== 'object') {
|
||||||
throw new Error("libcurl module is missing the libcurl export")
|
throw new Error('libcurl module is missing the libcurl export');
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadedLibcurl = candidate.libcurl as Partial<LibcurlApi>
|
const loadedLibcurl = candidate.libcurl as Partial<LibcurlApi>;
|
||||||
if (typeof loadedLibcurl.fetch !== "function") {
|
if (typeof loadedLibcurl.fetch !== 'function') {
|
||||||
throw new Error("libcurl export is missing fetch")
|
throw new Error('libcurl export is missing fetch');
|
||||||
}
|
}
|
||||||
if (typeof loadedLibcurl.load_wasm !== "function") {
|
if (typeof loadedLibcurl.load_wasm !== 'function') {
|
||||||
throw new Error("libcurl export is missing load_wasm")
|
throw new Error('libcurl export is missing load_wasm');
|
||||||
}
|
}
|
||||||
if (typeof loadedLibcurl.set_websocket !== "function") {
|
if (typeof loadedLibcurl.set_websocket !== 'function') {
|
||||||
throw new Error("libcurl export is missing set_websocket")
|
throw new Error('libcurl export is missing set_websocket');
|
||||||
}
|
}
|
||||||
|
|
||||||
return loadedLibcurl as LibcurlApi
|
return loadedLibcurl as LibcurlApi;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const loadLibcurl = async (): Promise<LibcurlApi> => {
|
export const loadLibcurl = async (): Promise<LibcurlApi> => {
|
||||||
if (!libcurlModulePromise) {
|
if (!libcurlModulePromise) {
|
||||||
libcurlModulePromise = import("./libcurl-entry.js").then((moduleValue) => {
|
libcurlModulePromise = import('./libcurl-entry.js').then((moduleValue) => {
|
||||||
const loadedLibcurl = toLibcurl(moduleValue)
|
const loadedLibcurl = toLibcurl(moduleValue);
|
||||||
libcurl = loadedLibcurl
|
libcurl = loadedLibcurl;
|
||||||
return loadedLibcurl
|
return loadedLibcurl;
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return await libcurlModulePromise
|
return await libcurlModulePromise;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const requireLibcurl = (): LibcurlApi => {
|
export const requireLibcurl = (): LibcurlApi => {
|
||||||
if (!libcurl) {
|
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;
|
||||||
}
|
};
|
||||||
|
|||||||
173
frontend/src/webmuxd-browser.d.ts
vendored
173
frontend/src/webmuxd-browser.d.ts
vendored
@@ -1,120 +1,123 @@
|
|||||||
declare module "webmuxd" {
|
declare module 'webmuxd' {
|
||||||
export interface WebUsbTransportInstance {
|
export interface WebUsbTransportInstance {
|
||||||
readonly isOpen: boolean
|
readonly isOpen: boolean;
|
||||||
open(): Promise<void>
|
open(): Promise<void>;
|
||||||
close(): Promise<void>
|
close(): Promise<void>;
|
||||||
send(data: ArrayBuffer): Promise<void>
|
send(data: ArrayBuffer): Promise<void>;
|
||||||
setDataHandler(handler: ((data: ArrayBuffer) => void) | null): void
|
setDataHandler(handler: ((data: ArrayBuffer) => void) | null): void;
|
||||||
setDisconnectHandler(handler: ((reason?: unknown) => void) | null): void
|
setDisconnectHandler(handler: ((reason?: unknown) => void) | null): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PairRecord {
|
export interface PairRecord {
|
||||||
hostId: string
|
hostId: string;
|
||||||
systemBuid: string
|
systemBuid: string;
|
||||||
hostCertificatePem: string
|
hostCertificatePem: string;
|
||||||
hostPrivateKeyPem: string
|
hostPrivateKeyPem: string;
|
||||||
rootCertificatePem: string
|
rootCertificatePem: string;
|
||||||
rootPrivateKeyPem: string
|
rootPrivateKeyPem: string;
|
||||||
deviceCertificatePem: string
|
deviceCertificatePem: string;
|
||||||
devicePublicKey: Uint8Array
|
devicePublicKey: Uint8Array;
|
||||||
escrowBag?: Uint8Array
|
escrowBag?: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoredPairRecordPayload {
|
export interface StoredPairRecordPayload {
|
||||||
hostId: string
|
hostId: string;
|
||||||
systemBuid: string
|
systemBuid: string;
|
||||||
hostCertificatePem: string
|
hostCertificatePem: string;
|
||||||
hostPrivateKeyPem: string
|
hostPrivateKeyPem: string;
|
||||||
rootCertificatePem: string
|
rootCertificatePem: string;
|
||||||
rootPrivateKeyPem: string
|
rootPrivateKeyPem: string;
|
||||||
deviceCertificatePem: string
|
deviceCertificatePem: string;
|
||||||
devicePublicKey: string
|
devicePublicKey: string;
|
||||||
escrowBag: string | null
|
escrowBag: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TlsConnection {
|
export interface TlsConnection {
|
||||||
is_handshaking(): boolean
|
is_handshaking(): boolean;
|
||||||
write_plaintext(data: Uint8Array): void
|
write_plaintext(data: Uint8Array): void;
|
||||||
feed_tls(data: Uint8Array): void
|
feed_tls(data: Uint8Array): void;
|
||||||
take_tls_out(): Uint8Array
|
take_tls_out(): Uint8Array;
|
||||||
take_plain_out(): Uint8Array
|
take_plain_out(): Uint8Array;
|
||||||
free(): void
|
free(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TlsConnectionFactory {
|
export interface TlsConnectionFactory {
|
||||||
ensureReady?(): Promise<void>
|
ensureReady?(): Promise<void>;
|
||||||
createConnection(request: {
|
createConnection(request: {
|
||||||
serverName: string
|
serverName: string;
|
||||||
caCertificatePem: string
|
caCertificatePem: string;
|
||||||
certificatePem: string
|
certificatePem: string;
|
||||||
privateKeyPem: string
|
privateKeyPem: string;
|
||||||
}): TlsConnection
|
}): TlsConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WebUsbTransport implements WebUsbTransportInstance {
|
export class WebUsbTransport implements WebUsbTransportInstance {
|
||||||
constructor(device: unknown, options?: { logger?: unknown; transferSize?: number })
|
constructor(device: unknown, options?: { logger?: unknown; transferSize?: number });
|
||||||
readonly isOpen: boolean
|
readonly isOpen: boolean;
|
||||||
static supported(): boolean
|
static supported(): boolean;
|
||||||
static requestAppleDevice(logger?: unknown): Promise<WebUsbTransport>
|
static requestAppleDevice(logger?: unknown): Promise<WebUsbTransport>;
|
||||||
open(): Promise<void>
|
open(): Promise<void>;
|
||||||
close(): Promise<void>
|
close(): Promise<void>;
|
||||||
send(data: ArrayBuffer): Promise<void>
|
send(data: ArrayBuffer): Promise<void>;
|
||||||
setDataHandler(handler: ((data: ArrayBuffer) => void) | null): void
|
setDataHandler(handler: ((data: ArrayBuffer) => void) | null): void;
|
||||||
setDisconnectHandler(handler: ((reason?: unknown) => void) | null): void
|
setDisconnectHandler(handler: ((reason?: unknown) => void) | null): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DirectUsbMuxClient {
|
export class DirectUsbMuxClient {
|
||||||
constructor(
|
constructor(
|
||||||
transport: WebUsbTransportInstance,
|
transport: WebUsbTransportInstance,
|
||||||
options?: {
|
options?: {
|
||||||
log?: (message: string) => void
|
log?: (message: string) => void;
|
||||||
onStateChange?: () => void
|
onStateChange?: () => void;
|
||||||
lockdownLabel?: string
|
lockdownLabel?: string;
|
||||||
tlsFactory?: TlsConnectionFactory
|
tlsFactory?: TlsConnectionFactory;
|
||||||
pairRecordFactory?: {
|
pairRecordFactory?: {
|
||||||
createPairRecord(request: {
|
createPairRecord(request: {
|
||||||
devicePublicKey: Uint8Array
|
devicePublicKey: Uint8Array;
|
||||||
hostId: string
|
hostId: string;
|
||||||
systemBuid: string
|
systemBuid: string;
|
||||||
}): Promise<PairRecord>
|
}): Promise<PairRecord>;
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
readonly isHandshakeComplete: boolean
|
readonly isHandshakeComplete: boolean;
|
||||||
readonly isLockdownConnected: boolean
|
readonly isLockdownConnected: boolean;
|
||||||
readonly isSessionStarted: boolean
|
readonly isSessionStarted: boolean;
|
||||||
readonly isSessionSslEnabled: boolean
|
readonly isSessionSslEnabled: boolean;
|
||||||
readonly isTlsActive: boolean
|
readonly isTlsActive: boolean;
|
||||||
readonly isPaired: boolean
|
readonly isPaired: boolean;
|
||||||
loadPairRecord(record: PairRecord | null): void
|
loadPairRecord(record: PairRecord | null): void;
|
||||||
openAndHandshake(): Promise<void>
|
openAndHandshake(): Promise<void>;
|
||||||
connectLockdown(port?: number): Promise<void>
|
connectLockdown(port?: number): Promise<void>;
|
||||||
getOrFetchDeviceUdid(): Promise<string>
|
getOrFetchDeviceUdid(): Promise<string>;
|
||||||
getOrFetchDeviceName(): Promise<string | null>
|
getOrFetchDeviceName(): Promise<string | null>;
|
||||||
pairDevice(hostId: string, systemBuid: string): Promise<PairRecord>
|
pairDevice(hostId: string, systemBuid: string): Promise<PairRecord>;
|
||||||
startSession(hostId: string, systemBuid: string): Promise<{
|
startSession(
|
||||||
sessionId: string
|
hostId: string,
|
||||||
enableSessionSsl: boolean
|
systemBuid: string,
|
||||||
}>
|
): Promise<{
|
||||||
close(): Promise<void>
|
sessionId: string;
|
||||||
|
enableSessionSsl: boolean;
|
||||||
|
}>;
|
||||||
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LOCKDOWN_PORT: number
|
export const LOCKDOWN_PORT: number;
|
||||||
export function installIpaViaInstProxy(
|
export function installIpaViaInstProxy(
|
||||||
client: DirectUsbMuxClient,
|
client: DirectUsbMuxClient,
|
||||||
ipaData: Uint8Array,
|
ipaData: Uint8Array,
|
||||||
fileName: string,
|
fileName: string,
|
||||||
onLog?: (message: string) => void,
|
onLog?: (message: string) => void,
|
||||||
): Promise<void>
|
): Promise<void>;
|
||||||
export function sanitizeIpaFileName(fileName: string): string
|
export function sanitizeIpaFileName(fileName: string): string;
|
||||||
export function createHostId(): string
|
export function createHostId(): string;
|
||||||
export function createSystemBuid(): string
|
export function createSystemBuid(): string;
|
||||||
export function encodeStoredPairRecord(record: PairRecord): StoredPairRecordPayload
|
export function encodeStoredPairRecord(record: PairRecord): StoredPairRecordPayload;
|
||||||
export function decodeStoredPairRecord(parsed: StoredPairRecordPayload): PairRecord | null
|
export function decodeStoredPairRecord(parsed: StoredPairRecordPayload): PairRecord | null;
|
||||||
export function createOpenSslWasmTlsFactory(): TlsConnectionFactory
|
export function createOpenSslWasmTlsFactory(): TlsConnectionFactory;
|
||||||
export function generatePairRecordWithOpenSslWasm(request: {
|
export function generatePairRecordWithOpenSslWasm(request: {
|
||||||
devicePublicKey: Uint8Array
|
devicePublicKey: Uint8Array;
|
||||||
hostId: string
|
hostId: string;
|
||||||
systemBuid: string
|
systemBuid: string;
|
||||||
}): Promise<string>
|
}): Promise<string>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
"types": ["vite/client"],
|
"types": ["vite/client"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
|
|||||||
@@ -1,34 +1,36 @@
|
|||||||
import { defineConfig } from "vite"
|
import { defineConfig } from 'vite';
|
||||||
import tailwindcss from "@tailwindcss/vite"
|
import react from '@vitejs/plugin-react';
|
||||||
import { dirname, resolve } from "node:path"
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import { fileURLToPath } from "node:url"
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const frontendDir = dirname(fileURLToPath(import.meta.url))
|
const frontendDir = dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRootDir = resolve(frontendDir, "..")
|
const repoRootDir = resolve(frontendDir, '..');
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
server: {
|
server: {
|
||||||
fs: {
|
fs: {
|
||||||
allow: [repoRootDir],
|
allow: [repoRootDir],
|
||||||
},
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
"/wisp": { target: "ws://localhost:8787", ws: true },
|
'/wisp': { target: 'ws://localhost:8787', ws: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
webmuxd: resolve(frontendDir, "src/webmuxd-browser.js"),
|
webmuxd: resolve(frontendDir, 'src/webmuxd-browser.js'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
|
include: ['jszip', 'node-forge', 'fflate'],
|
||||||
exclude: [
|
exclude: [
|
||||||
"altsign.js",
|
'altsign.js',
|
||||||
"@lbr77/anisette-js",
|
'@lbr77/anisette-js',
|
||||||
"@lbr77/anisette-js/browser",
|
'@lbr77/anisette-js/browser',
|
||||||
"@lbr77/zsign-wasm-resigner-wrapper",
|
'@lbr77/zsign-wasm-resigner-wrapper',
|
||||||
"libcurl.js",
|
'libcurl.js',
|
||||||
"libcurl.js/bundled",
|
'libcurl.js/bundled',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
@@ -36,4 +38,4 @@ export default defineConfig({
|
|||||||
include: [/node_modules/],
|
include: [/node_modules/],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
31
frontend/vitest.config.ts
Normal file
31
frontend/vitest.config.ts
Normal 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: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
51
nginx.conf
Normal file
51
nginx.conf
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
server {
|
||||||
|
listen 3000;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# MIME for WASM streaming compile.
|
||||||
|
types {
|
||||||
|
application/wasm wasm;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security headers.
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
# WebAssembly + crypto.subtle require secure-context; cross-origin isolation
|
||||||
|
# is needed for SharedArrayBuffer used by the signing WASM bundles.
|
||||||
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
|
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||||
|
|
||||||
|
# Compression.
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 256;
|
||||||
|
gzip_types
|
||||||
|
application/javascript
|
||||||
|
application/json
|
||||||
|
application/wasm
|
||||||
|
application/xml
|
||||||
|
text/css
|
||||||
|
text/javascript
|
||||||
|
text/plain
|
||||||
|
image/svg+xml;
|
||||||
|
|
||||||
|
# Hashed assets (Vite emits /assets/* with content hashes) — cache forever.
|
||||||
|
location /assets/ {
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Never cache the shell HTML.
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hash routing (no server-side routes needed) — fall back to index.html.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
package.json
10
package.json
@@ -13,13 +13,19 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "cd dependencies/webmuxd && bun run build",
|
"build": "cd dependencies/webmuxd && bun run build",
|
||||||
"build:webmuxd": "cd dependencies/webmuxd && bun run build",
|
"build:webmuxd": "cd dependencies/webmuxd && bun run build",
|
||||||
"build:frontend": "cd frontend && bun run build",
|
"build:frontend": "bun run build:wasm:dist && cd frontend && bun run build",
|
||||||
"build:backend": "cd backend && bun run check",
|
"build:backend": "cd backend && bun run check",
|
||||||
"build:wasm": "bun run build:wasm:openssl && bun run build:wasm:libcurl && bun run build:wasm:zsign",
|
"build:wasm": "bun run build:wasm:openssl && bun run build:wasm:libcurl && bun run build:wasm:zsign",
|
||||||
"build:wasm:openssl": "bash scripts/build-wasm-openssl.sh",
|
"build:wasm:openssl": "bash scripts/build-wasm-openssl.sh",
|
||||||
"build:wasm:libcurl": "bash scripts/build-wasm-libcurl.sh",
|
"build:wasm:libcurl": "bash scripts/build-wasm-libcurl.sh",
|
||||||
"build:wasm:zsign": "bash scripts/build-wasm-zsign.sh",
|
"build:wasm:zsign": "bash scripts/build-wasm-zsign.sh",
|
||||||
|
"setup": "git submodule update --init --recursive && bun install --ignore-scripts && bun run build:wasm:dist",
|
||||||
|
"build:wasm:dist": "bun wasm/openssl/build.mjs && ln -sfn pkg wasm/openssl/binary && bun wasm/libcurl-wasm/build.mjs && mkdir -p wasm/zsign-wasm/js/dist && cp wasm/zsign-wasm/js/npm/* wasm/zsign-wasm/js/dist/",
|
||||||
|
"dev": "bun run setup && mkdir -p frontend/dist && cd backend && bun x wrangler dev --port 8787 & cd frontend && bun run dev",
|
||||||
|
"dev:frontend": "bun run setup && cd frontend && bun run dev",
|
||||||
|
"dev:backend": "bun run setup && mkdir -p frontend/dist && cd backend && bun x wrangler dev --port 8787",
|
||||||
"lint": "cd dependencies/webmuxd && bun run lint",
|
"lint": "cd dependencies/webmuxd && bun run lint",
|
||||||
"test": "cd dependencies/webmuxd && bun run test"
|
"test": "cd dependencies/webmuxd && bun run test",
|
||||||
|
"test:frontend": "cd frontend && bun run test"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Submodule wasm/libcurl-wasm updated: a076de5661...f65d440879
1
wasm/openssl/binary
Symbolic link
1
wasm/openssl/binary
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
pkg
|
||||||
Reference in New Issue
Block a user