This commit is contained in:
2026-03-03 10:12:22 +08:00
commit ae4c58e56d
223 changed files with 42635 additions and 0 deletions

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1381
frontend/bun.lock Normal file

File diff suppressed because it is too large Load Diff

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebMuxD Demo</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

25
frontend/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"serve": "vite preview",
"preview": "vite preview"
},
"dependencies": {
"@lbr77/anisette-js": "0.1.3",
"@lbr77/zsign-wasm-resigner-wrapper": "^0.1.5",
"altsign.js": "^0.1.2",
"fflate": "^0.8.2",
"jszip": "^3.10.1",
"node-forge": "^1.3.3",
"webmuxd": "file:.."
},
"devDependencies": {
"typescript": "~5.9.3",
"vite": "^7.3.1"
}
}

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

View File

@@ -0,0 +1,26 @@
// @ts-ignore
import { libcurl } from "../public/anisette/libcurl_full.mjs"
let initialized = false
let initPromise: Promise<void> | null = null
export async function initLibcurl(): Promise<void> {
if (initialized) {
return
}
if (initPromise) {
return initPromise
}
initPromise = (async () => {
const wsProto = location.protocol === "https:" ? "wss:" : "ws:"
const wsUrl = `${wsProto}//${location.host}/wisp/`
libcurl.set_websocket(wsUrl)
await libcurl.load_wasm()
initialized = true
})()
return initPromise
}
export { libcurl }

View File

@@ -0,0 +1,76 @@
import { Anisette, loadWasmModule } from "@lbr77/anisette-js"
import { initLibcurl } from "./anisette-libcurl-init"
import { LibcurlHttpClient } from "./anisette-libcurl-http"
export interface AnisetteData {
machineID: string
oneTimePassword: string
localUserID: string
routingInfo: number
deviceUniqueIdentifier: string
deviceDescription: string
deviceSerialNumber: string
date: Date
locale: string
timeZone: string
}
let anisetteInstance: Anisette | null = null
export async function initAnisette(): Promise<Anisette> {
if (anisetteInstance) {
return anisetteInstance
}
await initLibcurl()
const httpClient = new LibcurlHttpClient()
const wasmModule = await loadWasmModule()
const [storeservicescore, coreadi] = await Promise.all([
fetch("/anisette/libstoreservicescore.so").then((response) => response.arrayBuffer()).then((arr) => new Uint8Array(arr)),
fetch("/anisette/libCoreADI.so").then((response) => response.arrayBuffer()).then((arr) => new Uint8Array(arr)),
])
anisetteInstance = await Anisette.fromSo(storeservicescore, coreadi, wasmModule, {
httpClient,
init: {
libraryPath: "./anisette/",
},
})
return anisetteInstance
}
export async function provisionAnisette(): Promise<void> {
const anisette = await initAnisette()
if (!anisette.isProvisioned) {
await anisette.provision()
}
}
export async function getAnisetteData(): Promise<AnisetteData> {
const anisette = await initAnisette()
if (!anisette.isProvisioned) {
await anisette.provision()
}
const headers = await anisette.getData()
return {
machineID: headers["X-Apple-I-MD-M"],
oneTimePassword: headers["X-Apple-I-MD"],
localUserID: headers["X-Apple-I-MD-LU"],
routingInfo: Number.parseInt(headers["X-Apple-I-MD-RINFO"], 10),
deviceUniqueIdentifier: headers["X-Mme-Device-Id"],
deviceDescription: headers["X-MMe-Client-Info"],
deviceSerialNumber: headers["X-Apple-I-SRL-NO"] || "0",
date: new Date(headers["X-Apple-I-Client-Time"]),
locale: headers["X-Apple-Locale"],
timeZone: headers["X-Apple-I-TimeZone"],
}
}
export function clearAnisetteCache(): void {
anisetteInstance = null
}

View File

@@ -0,0 +1,573 @@
import { strFromU8, unzipSync } from "fflate"
import {
AppleAPI,
Fetch,
signIPA,
type AnisetteData,
type AppID,
type Certificate,
type Device,
type Team,
} from "altsign.js"
import { initLibcurl, libcurl } from "./anisette-libcurl-init"
const SIGNING_IDENTITY_STORAGE_KEY = "webmuxd:signing-identities"
const PRIMARY_APP_INFO_PLIST_RE = /^Payload\/[^/]+\.app\/Info\.plist$/
interface ParsedIpaInfo {
bundleId?: string
displayName?: string
}
interface CachedSigningIdentityPayload {
certId: string
certPublicKeyBase64: string
privateKeyBase64: string
}
interface StoredSigningIdentityMap {
[appleAndTeamKey: string]: CachedSigningIdentityPayload
}
export interface AppleSigningCredentials {
appleId: string
password: string
}
export interface AppleSigningRequest {
ipaFile: File
anisetteData: AnisetteData
credentials: AppleSigningCredentials
deviceUdid: string
deviceName?: string
bundleIdOverride?: string
displayNameOverride?: string
onLog: (message: string) => void
}
export interface AppleSigningResult {
signedFile: File
outputBundleId: string
teamId: string
}
let appleApiInstance: AppleAPI | null = null
function getAppleApi(): AppleAPI {
if (appleApiInstance) {
return appleApiInstance
}
const appleFetch = new Fetch(initLibcurl, async (url, options) => {
const response = await libcurl.fetch(url, {
method: options.method,
headers: options.headers,
body: options.body,
redirect: "manual",
insecure: true,
_libcurl_http_version: 1.1,
} as never)
return response
})
appleApiInstance = new AppleAPI(appleFetch)
return appleApiInstance
}
export async function signIpaWithApple(
request: AppleSigningRequest,
): Promise<AppleSigningResult> {
const { ipaFile, anisetteData, credentials, onLog } = request
const ipaData = new Uint8Array(await ipaFile.arrayBuffer())
const ipaInfo = readIpaInfo(ipaData)
const bundleIdBase = (request.bundleIdOverride ?? ipaInfo.bundleId ?? "").trim()
if (bundleIdBase.length === 0) {
throw new Error("Cannot sign IPA: bundle identifier is missing")
}
const appleId = credentials.appleId.trim()
const password = credentials.password
if (!appleId || !password) {
throw new Error("Cannot sign IPA: Apple ID or password is empty")
}
onLog(`Signing stage: authenticating Apple account ${maskEmail(appleId)}...`)
const api = getAppleApi()
const { session } = await api.authenticate(appleId, password, anisetteData, (submitCode) => {
const code = window.prompt("Apple 2FA code")
if (!code || code.trim().length === 0) {
throw new Error("2FA code is required")
}
submitCode(code.trim())
})
const team = await api.fetchTeam(session)
onLog(`Signing stage: using team ${team.identifier} (${team.name}).`)
const finalBundleId = buildTeamScopedBundleId(bundleIdBase, team.identifier)
const displayName = (request.displayNameOverride ?? ipaInfo.displayName ?? "").trim()
const identity = await ensureSigningIdentity(api, session, team, appleId, onLog)
await ensureDeviceRegistered(
api,
session,
team,
request.deviceUdid,
request.deviceName,
onLog,
)
const appId = await ensureAppId(api, session, team, finalBundleId, onLog)
onLog("Signing stage: fetching provisioning profile...")
const provisioningProfile = await api.fetchProvisioningProfile(session, team, appId)
onLog("Signing stage: resigning IPA in browser...")
const signed = await signIPA({
ipaData,
certificate: identity.certificate.publicKey,
privateKey: identity.privateKey,
provisioningProfile: provisioningProfile.data,
bundleID: finalBundleId,
displayName: displayName.length > 0 ? displayName : undefined,
adhoc: false,
forceSign: true,
})
const outputFileName = toSignedFileName(ipaFile.name)
const signedArray = new Uint8Array(signed.data.byteLength)
signedArray.set(signed.data)
const signedBuffer = signedArray.buffer.slice(0)
const signedFile = new File([signedBuffer], outputFileName, {
type: "application/octet-stream",
})
onLog(`Signing stage: complete (${signed.data.byteLength} bytes).`)
return {
signedFile,
outputBundleId: finalBundleId,
teamId: team.identifier,
}
}
async function ensureSigningIdentity(
api: AppleAPI,
session: { anisetteData: AnisetteData; dsid: string; authToken: string },
team: Team,
appleId: string,
onLog: (message: string) => void,
): Promise<{ certificate: Certificate; privateKey: Uint8Array }> {
const certificates = await api.fetchCertificates(session, team)
const cached = loadCachedSigningIdentity(appleId, team.identifier)
if (cached) {
const matched = certificates.find((item) => item.identifier === cached.certId)
if (matched) {
onLog(`Signing stage: using cached certificate ${matched.identifier}.`)
return {
certificate: {
...matched,
publicKey: base64ToBytes(cached.certPublicKeyBase64),
},
privateKey: base64ToBytes(cached.privateKeyBase64),
}
}
}
onLog("Signing stage: creating development certificate...")
let created: { certificate: Certificate; privateKey: Uint8Array }
try {
created = await api.addCertificate(session, team, `webmuxd-${Date.now()}`)
} catch (error) {
const message = String(error)
if (!message.includes("7460") || certificates.length === 0) {
throw error
}
const target = certificates[0]
onLog(`Signing stage: certificate limit hit, revoking ${target.identifier}...`)
await api.revokeCertificate(session, team, target)
created = await api.addCertificate(session, team, `webmuxd-${Date.now()}`)
}
saveCachedSigningIdentity(appleId, team.identifier, {
certId: created.certificate.identifier,
certPublicKeyBase64: bytesToBase64(created.certificate.publicKey),
privateKeyBase64: bytesToBase64(created.privateKey),
})
onLog(`Signing stage: certificate ready ${created.certificate.identifier}.`)
return created
}
async function ensureDeviceRegistered(
api: AppleAPI,
session: { anisetteData: AnisetteData; dsid: string; authToken: string },
team: Team,
deviceUdid: string,
deviceName: string | undefined,
onLog: (message: string) => void,
): Promise<void> {
const normalizedUdid = normalizeUdid(deviceUdid)
if (!normalizedUdid) {
onLog("Signing stage: skip device registration because UDID is empty.")
return
}
let devices: Device[] = []
try {
devices = await api.fetchDevices(session, team)
} catch (error) {
onLog(`Signing stage: fetchDevices failed, skip registration check: ${formatError(error)}`)
}
const existed = findRegisteredDevice(devices, normalizedUdid)
if (existed) {
onLog(`Signing stage: device already registered (${existed.identifier}).`)
return
}
const registerName =
deviceName && deviceName.trim().length > 0
? deviceName.trim()
: `webmuxd-${normalizedUdid.slice(-6)}`
try {
onLog(`Signing stage: registering device ${normalizedUdid} as ${registerName}...`)
await api.registerDevice(session, team, registerName, normalizedUdid)
onLog(`Signing stage: device registered (${normalizedUdid}).`)
} catch (error) {
onLog(`Signing stage: register failed, skip and continue: ${formatError(error)}`)
try {
const latestDevices = await api.fetchDevices(session, team)
const registered = findRegisteredDevice(latestDevices, normalizedUdid)
if (registered) {
onLog(`Signing stage: device confirmed in developer list (${registered.identifier}).`)
return
}
} catch (verifyError) {
onLog(`Signing stage: device verify after failure also failed: ${formatError(verifyError)}`)
}
onLog("Signing stage: continue without registration (may affect profile generation).")
return
}
try {
const latestDevices = await api.fetchDevices(session, team)
const registered = findRegisteredDevice(latestDevices, normalizedUdid)
if (registered) {
onLog(`Signing stage: device confirmed in developer list (${registered.identifier}).`)
}
} catch (error) {
onLog(`Signing stage: device verification skipped: ${formatError(error)}`)
}
}
async function ensureAppId(
api: AppleAPI,
session: { anisetteData: AnisetteData; dsid: string; authToken: string },
team: Team,
bundleId: string,
onLog: (message: string) => void,
): Promise<AppID> {
const appIds = await api.fetchAppIDs(session, team)
const matched = appIds.find((item) => item.bundleIdentifier === bundleId)
if (matched) {
onLog(`Signing stage: reuse App ID ${bundleId}.`)
return matched
}
onLog(`Signing stage: creating App ID ${bundleId}...`)
return api.addAppID(session, team, "WebMuxD Signed App", bundleId)
}
function readIpaInfo(ipaBytes: Uint8Array): ParsedIpaInfo {
const files = unzipSync(ipaBytes, {
filter: (file) => PRIMARY_APP_INFO_PLIST_RE.test(file.name),
})
const infoName = Object.keys(files).find((name) => PRIMARY_APP_INFO_PLIST_RE.test(name))
if (!infoName) {
return {}
}
const plistData = parseInfoPlist(files[infoName])
if (!plistData || typeof plistData !== "object" || Array.isArray(plistData)) {
return {}
}
const data = plistData as Record<string, unknown>
const bundleId =
typeof data.CFBundleIdentifier === "string" ? (data.CFBundleIdentifier as string) : undefined
const displayName =
typeof data.CFBundleDisplayName === "string"
? (data.CFBundleDisplayName as string)
: typeof data.CFBundleName === "string"
? (data.CFBundleName as string)
: undefined
return { bundleId, displayName }
}
function parseInfoPlist(infoPlistBytes: Uint8Array): unknown {
if (strFromU8(infoPlistBytes.subarray(0, 8)) === "bplist00") {
return parseBinaryPlist(infoPlistBytes)
}
const xml = strFromU8(infoPlistBytes)
return parseXmlPlist(xml)
}
function parseXmlPlist(xml: string): unknown {
const doc = new DOMParser().parseFromString(xml, "application/xml")
const parserError = doc.querySelector("parsererror")
if (parserError) {
return {}
}
const root = doc.querySelector("plist > *")
if (!root) {
return {}
}
return parseXmlNode(root)
}
function parseXmlNode(node: Element): unknown {
switch (node.tagName) {
case "dict": {
const map: Record<string, unknown> = {}
const children = Array.from(node.children)
for (let i = 0; i < children.length - 1; i += 2) {
const keyNode = children[i]
const valueNode = children[i + 1]
if (keyNode.tagName !== "key") {
continue
}
map[keyNode.textContent ?? ""] = parseXmlNode(valueNode)
}
return map
}
case "array":
return Array.from(node.children).map((child) => parseXmlNode(child))
case "string":
case "date":
return node.textContent ?? ""
case "integer":
return Number.parseInt(node.textContent ?? "0", 10)
case "real":
return Number.parseFloat(node.textContent ?? "0")
case "true":
return true
case "false":
return false
case "data":
return base64ToBytes((node.textContent ?? "").trim())
default:
return node.textContent ?? ""
}
}
function parseBinaryPlist(bytes: Uint8Array): unknown {
if (bytes.length < 40 || strFromU8(bytes.subarray(0, 8)) !== "bplist00") {
throw new Error("Invalid binary plist")
}
const trailerOffset = bytes.length - 32
const offsetSize = bytes[trailerOffset + 6]
const objectRefSize = bytes[trailerOffset + 7]
const objectCount = readUInt(bytes, trailerOffset + 8, 8)
const topObject = readUInt(bytes, trailerOffset + 16, 8)
const offsetTableStart = readUInt(bytes, trailerOffset + 24, 8)
const objectOffsets = new Array<number>(objectCount)
for (let i = 0; i < objectCount; i += 1) {
const entryOffset = offsetTableStart + i * offsetSize
objectOffsets[i] = readUInt(bytes, entryOffset, offsetSize)
}
const memo = new Map<number, unknown>()
const readLength = (offset: number, objectInfo: number): { length: number; nextOffset: number } => {
if (objectInfo < 0x0f) {
return { length: objectInfo, nextOffset: offset + 1 }
}
const marker = bytes[offset + 1]
const markerType = marker >> 4
const markerInfo = marker & 0x0f
if (markerType !== 0x1) {
throw new Error("Invalid binary plist length marker")
}
const intSize = 1 << markerInfo
const intOffset = offset + 2
return {
length: readUInt(bytes, intOffset, intSize),
nextOffset: intOffset + intSize,
}
}
const parseObject = (index: number): unknown => {
if (memo.has(index)) {
return memo.get(index)
}
const offset = objectOffsets[index]
const marker = bytes[offset]
const objectType = marker >> 4
const objectInfo = marker & 0x0f
let value: unknown
if (objectType === 0x0) {
value = objectInfo === 0x8 ? false : objectInfo === 0x9
} else if (objectType === 0x1) {
value = readUInt(bytes, offset + 1, 1 << objectInfo)
} else if (objectType === 0x2) {
const realSize = 1 << objectInfo
const view = new DataView(bytes.buffer, bytes.byteOffset + offset + 1, realSize)
value = realSize === 4 ? view.getFloat32(0, false) : view.getFloat64(0, false)
} else if (objectType === 0x5) {
const { length, nextOffset } = readLength(offset, objectInfo)
value = strFromU8(bytes.subarray(nextOffset, nextOffset + length))
} else if (objectType === 0x6) {
const { length, nextOffset } = readLength(offset, objectInfo)
value = decodeUtf16Be(bytes.subarray(nextOffset, nextOffset + length * 2))
} else if (objectType === 0xa) {
const { length, nextOffset } = readLength(offset, objectInfo)
const items: unknown[] = []
for (let i = 0; i < length; i += 1) {
const ref = readUInt(bytes, nextOffset + i * objectRefSize, objectRefSize)
items.push(parseObject(ref))
}
value = items
} else if (objectType === 0xd) {
const { length, nextOffset } = readLength(offset, objectInfo)
const map: Record<string, unknown> = {}
const valuesOffset = nextOffset + length * objectRefSize
for (let i = 0; i < length; i += 1) {
const keyRef = readUInt(bytes, nextOffset + i * objectRefSize, objectRefSize)
const valueRef = readUInt(bytes, valuesOffset + i * objectRefSize, objectRefSize)
const key = parseObject(keyRef)
if (typeof key === "string") {
map[key] = parseObject(valueRef)
}
}
value = map
} else {
value = null
}
memo.set(index, value)
return value
}
return parseObject(topObject)
}
function readUInt(bytes: Uint8Array, offset: number, length: number): number {
let value = 0
for (let i = 0; i < length; i += 1) {
value = value * 256 + bytes[offset + i]
}
return value
}
function decodeUtf16Be(bytes: Uint8Array): string {
let text = ""
for (let i = 0; i + 1 < bytes.length; i += 2) {
text += String.fromCharCode((bytes[i] << 8) | bytes[i + 1])
}
return text
}
function buildTeamScopedBundleId(baseBundleId: string, teamId: string): string {
const trimmedBase = baseBundleId.trim()
const trimmedTeam = teamId.trim()
if (!trimmedBase || !trimmedTeam) {
return trimmedBase
}
const lowerBase = trimmedBase.toLowerCase()
const lowerTeam = trimmedTeam.toLowerCase()
if (lowerBase.endsWith(`.${lowerTeam}`)) {
return trimmedBase
}
return `${trimmedBase}.${trimmedTeam}`
}
function toSignedFileName(name: string): string {
if (!name.toLowerCase().endsWith(".ipa")) {
return `${name}-signed.ipa`
}
return `${name.slice(0, -4)}-signed.ipa`
}
function loadCachedSigningIdentity(
appleId: string,
teamId: string,
): CachedSigningIdentityPayload | null {
const map = loadSigningIdentityMap()
const key = signingIdentityKey(appleId, teamId)
const value = map[key]
if (!value || !value.certId || !value.certPublicKeyBase64 || !value.privateKeyBase64) {
return null
}
return value
}
function saveCachedSigningIdentity(
appleId: string,
teamId: string,
payload: CachedSigningIdentityPayload,
): void {
const map = loadSigningIdentityMap()
map[signingIdentityKey(appleId, teamId)] = payload
window.localStorage.setItem(SIGNING_IDENTITY_STORAGE_KEY, JSON.stringify(map))
}
function loadSigningIdentityMap(): StoredSigningIdentityMap {
const raw = window.localStorage.getItem(SIGNING_IDENTITY_STORAGE_KEY)
if (!raw) {
return {}
}
try {
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {}
}
return parsed as StoredSigningIdentityMap
} catch {
return {}
}
}
function signingIdentityKey(appleId: string, teamId: string): string {
return `${appleId.trim().toLowerCase()}::${teamId.trim().toUpperCase()}`
}
function bytesToBase64(value: Uint8Array): string {
let binary = ""
for (const byte of value) {
binary += String.fromCharCode(byte)
}
return btoa(binary)
}
function base64ToBytes(base64: string): Uint8Array {
const normalized = base64.replace(/\s+/g, "")
const binary = atob(normalized)
const out = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) {
out[i] = binary.charCodeAt(i)
}
return out
}
function maskEmail(value: string): string {
const trimmed = value.trim()
const at = trimmed.indexOf("@")
if (at <= 1) {
return "***"
}
return `${trimmed.slice(0, 2)}***${trimmed.slice(at)}`
}
function findRegisteredDevice(devices: readonly Device[], normalizedUdid: string): Device | null {
return (
devices.find((item) => normalizeUdid(item.identifier) === normalizedUdid) ?? null
)
}
function normalizeUdid(value: string): string {
return value.trim().toUpperCase()
}
function formatError(error: unknown): string {
if (error instanceof Error) {
return error.message
}
return String(error)
}

9
frontend/src/counter.ts Normal file
View File

@@ -0,0 +1,9 @@
export function setupCounter(element: HTMLButtonElement) {
let counter = 0
const setCounter = (count: number) => {
counter = count
element.innerHTML = `count is ${counter}`
}
element.addEventListener('click', () => setCounter(counter + 1))
setCounter(0)
}

775
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,775 @@
import "./style.css"
import * as webmuxdModule from "webmuxd"
import { getAnisetteData, provisionAnisette, type AnisetteData } from "./anisette-service"
import { signIpaWithApple } from "./apple-signing"
import initOpensslWasm, {
libimobiledevice_generate_pair_record,
OpensslClient,
} from "../../tls/openssl-wasm/pkg/openssl_wasm.js"
interface WebUsbTransportInstance {
readonly isOpen: boolean
open(): Promise<void>
close(): Promise<void>
send(data: ArrayBuffer): Promise<void>
setDataHandler(handler: ((data: ArrayBuffer) => void) | null): void
setDisconnectHandler(handler: ((reason?: unknown) => void) | null): void
}
interface WebUsbTransportCtor {
supported(): boolean
requestAppleDevice(): Promise<WebUsbTransportInstance>
}
interface PairRecord {
hostId: string
systemBuid: string
hostCertificatePem: string
hostPrivateKeyPem: string
rootCertificatePem: string
rootPrivateKeyPem: string
deviceCertificatePem: string
devicePublicKey: Uint8Array
escrowBag?: Uint8Array
}
interface StoredPairRecordPayload {
hostId: string
systemBuid: string
hostCertificatePem: string
hostPrivateKeyPem: string
rootCertificatePem: string
rootPrivateKeyPem: string
deviceCertificatePem: string
devicePublicKey: string
escrowBag: string | null
}
interface StartSessionResult {
sessionId: string
enableSessionSsl: boolean
}
interface DirectUsbMuxClient {
readonly isHandshakeComplete: boolean
readonly isLockdownConnected: boolean
readonly isAfcConnected: boolean
readonly isInstProxyConnected: boolean
readonly isSessionStarted: boolean
readonly isSessionSslEnabled: boolean
readonly isTlsActive: boolean
readonly isPaired: boolean
loadPairRecord(record: PairRecord | null): void
openAndHandshake(): Promise<void>
connectLockdown(port?: number): Promise<void>
getOrFetchDeviceUdid(): Promise<string>
getOrFetchDeviceName(): Promise<string | null>
pairDevice(hostId: string, systemBuid: string): Promise<PairRecord>
startSession(hostId: string, systemBuid: string): Promise<StartSessionResult>
close(): Promise<void>
}
interface DirectUsbMuxClientCtor {
new (
transport: WebUsbTransportInstance,
options?: {
log?: (message: string) => void
onStateChange?: () => void
lockdownLabel?: string
tlsFactory?: {
ensureReady?: () => Promise<void>
createConnection(request: {
serverName: string
caCertificatePem: string
certificatePem: string
privateKeyPem: string
}): {
is_handshaking(): boolean
write_plaintext(data: Uint8Array): void
feed_tls(data: Uint8Array): void
take_tls_out(): Uint8Array
take_plain_out(): Uint8Array
free(): void
}
}
pairRecordFactory?: {
createPairRecord(request: {
devicePublicKey: Uint8Array
hostId: string
systemBuid: string
}): Promise<PairRecord>
}
},
): DirectUsbMuxClient
}
interface SigningPreflightContext {
anisetteData: AnisetteData
preparedAtIso: string
}
interface WasmPairRecordPayload {
hostId: string
systemBuid: string
hostCertificatePem: string
hostPrivateKeyPem: string
rootCertificatePem: string
rootPrivateKeyPem: string
deviceCertificatePem: string
}
const LOCKDOWN_PORT = 62078
const HOST_ID_STORAGE_KEY = "webmuxd:host-id"
const SYSTEM_BUID_STORAGE_KEY = "webmuxd:system-buid"
const PAIR_RECORDS_STORAGE_KEY = "webmuxd:pair-records-by-udid"
const LEGACY_PAIR_RECORD_STORAGE_KEY = "webmuxd:pair-record"
const APPLE_ID_STORAGE_KEY = "webmuxd:apple-id"
const SIGN_BUNDLE_ID_STORAGE_KEY = "webmuxd:sign-bundle-id"
const SIGN_DISPLAY_NAME_STORAGE_KEY = "webmuxd:sign-display-name"
const ENABLE_BROWSER_SIGNING_PIPELINE = true
const SIGNING_PREFLIGHT_REQUIRED = false
const webmuxdModuleValue = webmuxdModule as unknown as Record<string, unknown>
const WebUsbTransport = resolveWebmuxdExport<WebUsbTransportCtor>(
webmuxdModuleValue,
"WebUsbTransport",
)
const WebmuxdDirectUsbMuxClient = resolveWebmuxdExport<DirectUsbMuxClientCtor>(
webmuxdModuleValue,
"DirectUsbMuxClient",
)
const webmuxdInstallIpaViaInstProxy = resolveWebmuxdExport<
(
client: DirectUsbMuxClient,
ipaData: Uint8Array,
fileName: string,
onLog?: (message: string) => void,
) => Promise<void>
>(webmuxdModuleValue, "installIpaViaInstProxy")
const webmuxdSanitizeIpaFileName = resolveWebmuxdExport<(fileName: string) => string>(
webmuxdModuleValue,
"sanitizeIpaFileName",
)
const webmuxdCreateHostId = resolveWebmuxdExport<() => string>(
webmuxdModuleValue,
"createHostId",
)
const webmuxdCreateSystemBuid = resolveWebmuxdExport<() => string>(
webmuxdModuleValue,
"createSystemBuid",
)
const webmuxdEncodeStoredPairRecord = resolveWebmuxdExport<
(record: PairRecord) => StoredPairRecordPayload
>(webmuxdModuleValue, "encodeStoredPairRecord")
const webmuxdDecodeStoredPairRecord = resolveWebmuxdExport<
(payload: StoredPairRecordPayload) => PairRecord | null
>(webmuxdModuleValue, "decodeStoredPairRecord")
let opensslInitPromise: Promise<void> | null = null
const ensureOpensslReady = async (): Promise<void> => {
if (!opensslInitPromise) {
opensslInitPromise = initOpensslWasm().then(() => undefined)
}
await opensslInitPromise
}
const app = document.querySelector<HTMLDivElement>("#app")
if (!app) {
throw new Error("App root is missing")
}
app.innerHTML = `
<main class="shell">
<section class="panel">
<p class="eyebrow">Pure Browser usbmux + lockdown</p>
<h1>WebMuxD Direct Mode</h1>
<p class="subline">
Handshake with iPhone USB MUX directly, then connect lockdownd (62078).
</p>
<div id="ipa-drop-zone" class="drop-zone">
Drag & drop IPA here to run full flow: Signing Preflight -> Select Device -> Session -> Upload -> Install
</div>
<div class="request-row">
<label for="host-id">HostID</label>
<input id="host-id" type="text" />
</div>
<div class="request-row">
<label for="system-buid">SystemBUID</label>
<input id="system-buid" type="text" />
</div>
<div class="request-row">
<label for="apple-id">Apple ID</label>
<input id="apple-id" type="email" autocomplete="username" />
</div>
<div class="request-row">
<label for="apple-password">Apple Password</label>
<input id="apple-password" type="password" autocomplete="current-password" />
</div>
<div class="request-row">
<label for="sign-bundle-id">Sign BundleID</label>
<input id="sign-bundle-id" type="text" placeholder="optional" />
</div>
<div class="request-row">
<label for="sign-display-name">Sign DisplayName</label>
<input id="sign-display-name" type="text" placeholder="optional" />
</div>
<div class="request-row">
<label for="ipa-file">Select IPA</label>
<input id="ipa-file" type="file" accept=".ipa,application/octet-stream" />
</div>
<div class="state-grid">
<div class="state-item">
<span class="label">WebUSB</span>
<span id="support-state" class="value"></span>
</div>
<div class="state-item">
<span class="label">Transport</span>
<span id="transport-state" class="value"></span>
</div>
<div class="state-item">
<span class="label">MUX</span>
<span id="mux-state" class="value"></span>
</div>
<div class="state-item">
<span class="label">Lockdown TCP</span>
<span id="lockdown-state" class="value"></span>
</div>
<div class="state-item">
<span class="label">Pair</span>
<span id="pair-state" class="value"></span>
</div>
<div class="state-item">
<span class="label">Lockdown Session</span>
<span id="session-state" class="value"></span>
</div>
<div class="state-item">
<span class="label">AFC</span>
<span id="afc-state" class="value"></span>
</div>
<div class="state-item">
<span class="label">InstProxy</span>
<span id="instproxy-state" class="value"></span>
</div>
</div>
</section>
<section class="log-panel">
<div class="log-head">
<h2>Event Log</h2>
</div>
<pre id="log" class="log"></pre>
</section>
</main>
`
const hostIdInput = mustGetInput("host-id")
const systemBuidInput = mustGetInput("system-buid")
const appleIdInput = mustGetInput("apple-id")
const applePasswordInput = mustGetInput("apple-password")
const signBundleIdInput = mustGetInput("sign-bundle-id")
const signDisplayNameInput = mustGetInput("sign-display-name")
const ipaFileInput = mustGetInput("ipa-file")
const supportState = mustGetElement("support-state")
const transportState = mustGetElement("transport-state")
const muxState = mustGetElement("mux-state")
const lockdownState = mustGetElement("lockdown-state")
const pairState = mustGetElement("pair-state")
const sessionState = mustGetElement("session-state")
const afcState = mustGetElement("afc-state")
const instProxyState = mustGetElement("instproxy-state")
const ipaDropZone = mustGetElement("ipa-drop-zone")
const logView = mustGetElement("log")
const logLines: string[] = []
const isSupported = WebUsbTransport.supported()
let directClient: DirectUsbMuxClient | null = null
let installFlowInProgress = false
let signingPreflightPromise: Promise<SigningPreflightContext | null> | null = null
hostIdInput.value = getOrCreateHostId()
systemBuidInput.value = getOrCreateSystemBuid()
appleIdInput.value = loadText(APPLE_ID_STORAGE_KEY) ?? ""
applePasswordInput.value = ""
signBundleIdInput.value = loadText(SIGN_BUNDLE_ID_STORAGE_KEY) ?? ""
signDisplayNameInput.value = loadText(SIGN_DISPLAY_NAME_STORAGE_KEY) ?? ""
const addLog = (message: string): void => {
const now = new Date()
const time = `${now.toLocaleTimeString()}.${String(now.getMilliseconds()).padStart(3, "0")}`
logLines.push(`[${time}] ${message}`)
logView.textContent = logLines.slice(-200).join("\n")
}
const refreshState = (): void => {
supportState.textContent = isSupported ? "supported" : "not supported"
transportState.textContent = directClient ? "selected" : "not selected"
muxState.textContent = directClient?.isHandshakeComplete ? "ready" : "pending"
lockdownState.textContent = directClient?.isLockdownConnected ? "connected" : "disconnected"
pairState.textContent = directClient?.isPaired ? "paired" : "not paired"
sessionState.textContent = directClient?.isSessionStarted
? directClient.isSessionSslEnabled
? directClient.isTlsActive
? "started (tls active)"
: "started (ssl required)"
: "started"
: "not started"
afcState.textContent = directClient?.isAfcConnected ? "connected" : "disconnected"
instProxyState.textContent = directClient?.isInstProxyConnected ? "connected" : "disconnected"
}
const ensureClientSelected = async (): Promise<DirectUsbMuxClient> => {
if (directClient) {
return directClient
}
const transport = await WebUsbTransport.requestAppleDevice()
directClient = new WebmuxdDirectUsbMuxClient(transport, {
log: addLog,
onStateChange: refreshState,
lockdownLabel: "webmuxd.frontend",
tlsFactory: {
ensureReady: ensureOpensslReady,
createConnection: (request) => {
return new OpensslClient(
request.serverName,
request.caCertificatePem,
request.certificatePem,
request.privateKeyPem,
)
},
},
pairRecordFactory: {
createPairRecord: async (request) => {
return await createPairRecord(request.devicePublicKey, request.hostId, request.systemBuid)
},
},
})
addLog("Apple device selected.")
refreshState()
return directClient
}
const getSigningCredentials = (): { appleId: string; password: string } | null => {
const appleId = appleIdInput.value.trim()
const password = applePasswordInput.value
if (!appleId || !password) {
return null
}
return { appleId, password }
}
const prepareSigningPreflight = async (
log: (message: string) => void,
): Promise<SigningPreflightContext | null> => {
if (signingPreflightPromise) {
return signingPreflightPromise
}
signingPreflightPromise = (async () => {
try {
log("Signing preflight: provisioning anisette...")
await provisionAnisette()
const anisetteData = await getAnisetteData()
const context: SigningPreflightContext = {
anisetteData,
preparedAtIso: new Date().toISOString(),
}
log(
`Signing preflight ready: machineID=${shortToken(anisetteData.machineID)}, locale=${anisetteData.locale}, timezone=${anisetteData.timeZone}`,
)
return context
} catch (error) {
const message = formatError(error)
log(`Signing preflight failed: ${message}`)
if (SIGNING_PREFLIGHT_REQUIRED) {
throw error
}
log("Signing preflight skipped (non-blocking mode).")
return null
}
})()
try {
return await signingPreflightPromise
} finally {
signingPreflightPromise = null
}
}
const prepareIpaForInstall = async (
ipaFile: File,
log: (message: string) => void,
device: { udid: string; name?: string },
): Promise<{ installFile: File; signingContext: SigningPreflightContext | null }> => {
const signingContext = await prepareSigningPreflight(log)
if (signingContext) {
log(`Signing preflight timestamp: ${signingContext.preparedAtIso}`)
}
if (!ENABLE_BROWSER_SIGNING_PIPELINE) {
log("Signing stage: browser resign pipeline is disabled, using original IPA.")
return { installFile: ipaFile, signingContext }
}
if (!signingContext) {
log("Signing stage: anisette is unavailable, using original IPA.")
return { installFile: ipaFile, signingContext: null }
}
const credentials = getSigningCredentials()
if (!credentials) {
log("Signing stage: Apple ID/password not set, using original IPA.")
return { installFile: ipaFile, signingContext }
}
const bundleIdOverride = signBundleIdInput.value.trim()
const displayNameOverride = signDisplayNameInput.value.trim()
const signingResult = await signIpaWithApple({
ipaFile,
anisetteData: signingContext.anisetteData,
credentials,
deviceUdid: device.udid,
deviceName: device.name,
bundleIdOverride: bundleIdOverride.length > 0 ? bundleIdOverride : undefined,
displayNameOverride: displayNameOverride.length > 0 ? displayNameOverride : undefined,
onLog: log,
})
log(
`Signing stage: signed IPA ready (${signingResult.signedFile.name}), bundleId=${signingResult.outputBundleId}, team=${signingResult.teamId}.`,
)
return { installFile: signingResult.signedFile, signingContext }
}
const installIpaViaInstProxy = async (
client: DirectUsbMuxClient,
ipaFile: File,
log: (message: string) => void,
): Promise<void> => {
const rawData = new Uint8Array(await ipaFile.arrayBuffer())
const safeFileName = webmuxdSanitizeIpaFileName(ipaFile.name)
await webmuxdInstallIpaViaInstProxy(client, rawData, safeFileName, log)
}
const runFullInstallFlow = async (ipaFile: File): Promise<void> => {
if (installFlowInProgress) {
throw new Error("Install flow is already running")
}
installFlowInProgress = true
try {
addLog(`Full install start: ${ipaFile.name} (${ipaFile.size} bytes).`)
const client = await ensureClientSelected()
let hostId = hostIdInput.value.trim()
let systemBuid = systemBuidInput.value.trim()
if (hostId.length === 0) {
hostId = getOrCreateHostId()
hostIdInput.value = hostId
}
if (systemBuid.length === 0) {
systemBuid = getOrCreateSystemBuid()
systemBuidInput.value = systemBuid
}
saveText(HOST_ID_STORAGE_KEY, hostId)
saveText(SYSTEM_BUID_STORAGE_KEY, systemBuid)
if (!client.isHandshakeComplete) {
await client.openAndHandshake()
}
if (!client.isLockdownConnected) {
await client.connectLockdown(LOCKDOWN_PORT)
}
const deviceUdid = await client.getOrFetchDeviceUdid()
const deviceName = await client.getOrFetchDeviceName()
addLog(`Device UDID: ${deviceUdid}`)
if (deviceName) {
addLog(`Device Name: ${deviceName}`)
}
const prepared = await prepareIpaForInstall(ipaFile, addLog, {
udid: deviceUdid,
name: deviceName ?? undefined,
})
const storedPair = loadPairRecordForUdid(deviceUdid)
if (storedPair && !client.isPaired) {
client.loadPairRecord(storedPair)
hostId = storedPair.hostId
systemBuid = storedPair.systemBuid
hostIdInput.value = hostId
systemBuidInput.value = systemBuid
saveText(HOST_ID_STORAGE_KEY, hostId)
saveText(SYSTEM_BUID_STORAGE_KEY, systemBuid)
addLog(`Loaded pair record for device ${deviceUdid}.`)
}
if (!client.isPaired) {
const pairResult = await client.pairDevice(hostId, systemBuid)
savePairRecordForUdid(deviceUdid, pairResult)
addLog(`Pair success and pair record saved for ${deviceUdid}.`)
}
if (!client.isSessionStarted) {
const session = await client.startSession(hostId, systemBuid)
addLog(
`StartSession success: SessionID=${session.sessionId}, EnableSessionSSL=${String(session.enableSessionSsl)}`,
)
}
await installIpaViaInstProxy(client, prepared.installFile, addLog)
} finally {
installFlowInProgress = false
}
}
const handleSelectedIpaFile = async (file: File): Promise<void> => {
addLog(`IPA selected: ${file.name} (${file.size} bytes).`)
refreshState()
try {
await runFullInstallFlow(file)
} catch (error) {
addLog(`Install IPA failed: ${formatError(error)}`)
} finally {
refreshState()
}
}
appleIdInput.addEventListener("change", () => {
saveText(APPLE_ID_STORAGE_KEY, appleIdInput.value.trim())
})
signBundleIdInput.addEventListener("change", () => {
saveText(SIGN_BUNDLE_ID_STORAGE_KEY, signBundleIdInput.value.trim())
})
signDisplayNameInput.addEventListener("change", () => {
saveText(SIGN_DISPLAY_NAME_STORAGE_KEY, signDisplayNameInput.value.trim())
})
ipaFileInput.addEventListener("change", async () => {
const file = ipaFileInput.files && ipaFileInput.files.length > 0 ? ipaFileInput.files[0] : null
if (!file) {
addLog("IPA selection cleared.")
refreshState()
return
}
await handleSelectedIpaFile(file)
})
ipaDropZone.addEventListener("dragenter", (event) => {
event.preventDefault()
ipaDropZone.classList.add("dragover")
})
ipaDropZone.addEventListener("dragover", (event) => {
event.preventDefault()
ipaDropZone.classList.add("dragover")
})
ipaDropZone.addEventListener("dragleave", () => {
ipaDropZone.classList.remove("dragover")
})
ipaDropZone.addEventListener("drop", async (event) => {
event.preventDefault()
ipaDropZone.classList.remove("dragover")
const file = event.dataTransfer?.files?.[0] ?? null
if (!file) {
addLog("Drop ignored: no file.")
return
}
await handleSelectedIpaFile(file)
})
window.addEventListener("beforeunload", () => {
void directClient?.close()
})
addLog("Demo ready.")
refreshState()
function mustGetElement(id: string): HTMLElement {
const element = document.getElementById(id)
if (!element) {
throw new Error(`Element #${id} not found`)
}
return element
}
function mustGetInput(id: string): HTMLInputElement {
const element = document.getElementById(id)
if (!element || !(element instanceof HTMLInputElement)) {
throw new Error(`Input #${id} not found`)
}
return element
}
function loadText(key: string): string | null {
return window.localStorage.getItem(key)
}
function saveText(key: string, value: string): void {
window.localStorage.setItem(key, value)
}
function getOrCreateHostId(): string {
const existing = loadText(HOST_ID_STORAGE_KEY)
if (existing && existing.trim().length > 0) {
return existing
}
const created = webmuxdCreateHostId()
saveText(HOST_ID_STORAGE_KEY, created)
return created
}
function getOrCreateSystemBuid(): string {
const existing = loadText(SYSTEM_BUID_STORAGE_KEY)
if (existing && existing.trim().length > 0) {
return existing
}
const created = webmuxdCreateSystemBuid()
saveText(SYSTEM_BUID_STORAGE_KEY, created)
return created
}
function readPairRecordMap(): Record<string, StoredPairRecordPayload> {
const text = loadText(PAIR_RECORDS_STORAGE_KEY)
if (!text) {
return {}
}
try {
const parsed = JSON.parse(text) as unknown
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {}
}
return parsed as Record<string, StoredPairRecordPayload>
} catch {
return {}
}
}
function writePairRecordMap(map: Record<string, StoredPairRecordPayload>): void {
saveText(PAIR_RECORDS_STORAGE_KEY, JSON.stringify(map))
}
function savePairRecordForUdid(udid: string, record: PairRecord): void {
const normalizedUdid = udid.trim()
if (normalizedUdid.length === 0) {
return
}
const map = readPairRecordMap()
map[normalizedUdid] = webmuxdEncodeStoredPairRecord(record)
writePairRecordMap(map)
}
function loadLegacyPairRecord(): PairRecord | null {
const text = loadText(LEGACY_PAIR_RECORD_STORAGE_KEY)
if (!text) {
return null
}
try {
const parsed = JSON.parse(text) as StoredPairRecordPayload
return webmuxdDecodeStoredPairRecord(parsed)
} catch {
return null
}
}
function loadPairRecordForUdid(udid: string): PairRecord | null {
const normalizedUdid = udid.trim()
if (normalizedUdid.length === 0) {
return null
}
const map = readPairRecordMap()
const fromMap = map[normalizedUdid]
if (fromMap) {
try {
return webmuxdDecodeStoredPairRecord(fromMap)
} catch {
return null
}
}
const legacy = loadLegacyPairRecord()
if (legacy) {
savePairRecordForUdid(normalizedUdid, legacy)
window.localStorage.removeItem(LEGACY_PAIR_RECORD_STORAGE_KEY)
}
return legacy
}
async function createPairRecord(
devicePublicKeyBytes: Uint8Array,
hostId: string,
systemBuid: string,
): Promise<PairRecord> {
await ensureOpensslReady()
const payloadText = libimobiledevice_generate_pair_record(
new Uint8Array(devicePublicKeyBytes),
hostId,
systemBuid,
)
const payload = JSON.parse(payloadText) as WasmPairRecordPayload
return {
hostId: payload.hostId,
systemBuid: payload.systemBuid,
hostCertificatePem: normalizePem(payload.hostCertificatePem),
hostPrivateKeyPem: normalizePem(payload.hostPrivateKeyPem),
rootCertificatePem: normalizePem(payload.rootCertificatePem),
rootPrivateKeyPem: normalizePem(payload.rootPrivateKeyPem),
deviceCertificatePem: normalizePem(payload.deviceCertificatePem),
devicePublicKey: new Uint8Array(devicePublicKeyBytes),
}
}
function normalizePem(value: string): string {
const normalized = value.replace(/\0/g, "").replace(/\r\n/g, "\n").trim()
return `${normalized}\n`
}
function shortToken(value: string): string {
const text = value.trim()
if (text.length <= 10) {
return text
}
return `${text.slice(0, 6)}...${text.slice(-4)}`
}
function formatError(error: unknown): string {
if (error instanceof Error) {
return error.message
}
return String(error)
}
function resolveWebmuxdExport<T>(moduleValue: Record<string, unknown>, key: string): T {
const direct = moduleValue[key]
if (direct !== undefined) {
return direct as T
}
const defaultValue = moduleValue.default
if (defaultValue && typeof defaultValue === "object") {
const fromDefault = (defaultValue as Record<string, unknown>)[key]
if (fromDefault !== undefined) {
return fromDefault as T
}
}
throw new Error(`webmuxd export ${key} is unavailable`)
}

2
frontend/src/node-forge.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare module "node-forge"

196
frontend/src/style.css Normal file
View File

@@ -0,0 +1,196 @@
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap");
:root {
--bg-top: #f7f1e6;
--bg-bottom: #e8dcc3;
--panel: rgba(255, 249, 238, 0.82);
--ink: #1f2a36;
--ink-soft: #5c6672;
--accent: #d64f2a;
--accent-2: #0c7f7a;
--border: rgba(31, 42, 54, 0.16);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
font-family: "Space Grotesk", "Segoe UI", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at 14% 16%, rgba(214, 79, 42, 0.3), transparent 42%),
radial-gradient(circle at 82% 8%, rgba(12, 127, 122, 0.25), transparent 36%),
linear-gradient(170deg, var(--bg-top), var(--bg-bottom));
}
#app {
max-width: 1160px;
margin: 0 auto;
padding: 32px 20px 48px;
}
.shell {
display: grid;
gap: 16px;
grid-template-columns: 1.1fr 0.9fr;
}
.panel,
.log-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 20px;
backdrop-filter: blur(6px);
}
.panel {
padding: 28px;
}
.eyebrow {
margin: 0;
color: var(--accent-2);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 12px;
}
h1 {
margin: 10px 0 4px;
font-size: clamp(1.8rem, 5vw, 2.8rem);
line-height: 1.05;
}
.subline {
margin: 0;
color: var(--ink-soft);
}
.drop-zone {
margin-top: 18px;
border: 2px dashed rgba(12, 127, 122, 0.45);
border-radius: 14px;
padding: 16px 14px;
text-align: center;
font-size: 13px;
font-weight: 600;
color: var(--accent-2);
background: rgba(12, 127, 122, 0.08);
transition: border-color 120ms ease, background-color 120ms ease, transform 120ms ease;
}
.drop-zone.dragover {
border-color: var(--accent);
background: rgba(214, 79, 42, 0.12);
transform: translateY(-1px);
}
.request-row {
margin-top: 12px;
display: grid;
grid-template-columns: 140px 1fr;
gap: 10px;
align-items: center;
}
.request-row label {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-soft);
}
.request-row input {
width: 100%;
border-radius: 10px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.7);
color: var(--ink);
padding: 10px 12px;
font-size: 14px;
font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, monospace;
}
.request-row input:focus {
outline: 2px solid rgba(12, 127, 122, 0.24);
border-color: rgba(12, 127, 122, 0.45);
}
.state-grid {
margin-top: 20px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
}
.state-item {
border: 1px solid var(--border);
border-radius: 12px;
padding: 10px 12px;
display: grid;
gap: 6px;
}
.label {
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-soft);
}
.value {
font-size: 16px;
font-weight: 700;
}
.log-panel {
padding: 20px;
display: grid;
grid-template-rows: auto 1fr;
min-height: 430px;
}
.log-head {
display: flex;
justify-content: space-between;
align-items: center;
}
h2 {
margin: 0;
font-size: 18px;
}
.log {
margin: 12px 0 0;
padding: 14px;
border-radius: 14px;
border: 1px solid var(--border);
background: rgba(28, 36, 46, 0.96);
color: #d5f4ef;
font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, monospace;
font-size: 12px;
line-height: 1.45;
overflow: auto;
white-space: pre-wrap;
}
@media (max-width: 860px) {
.shell {
grid-template-columns: 1fr;
}
.request-row {
grid-template-columns: 1fr;
}
.state-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"></path><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

4
frontend/src/webmuxd-shim.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module "webmuxd" {
const webmuxd: Record<string, unknown>
export default webmuxd
}

26
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

29
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,29 @@
import { defineConfig } from "vite"
import { dirname, resolve } from "node:path"
import { fileURLToPath } from "node:url"
const frontendDir = dirname(fileURLToPath(import.meta.url))
const repoRootDir = resolve(frontendDir, "..")
export default defineConfig({
server: {
fs: {
allow: [repoRootDir],
},
proxy: {
"/api": "http://localhost:8080",
"/wisp": { target: "ws://localhost:8080", ws: true },
},
},
resolve: {
preserveSymlinks: true,
},
optimizeDeps: {
include: ["webmuxd", "@lbr77/anisette-js/browser"],
},
build: {
commonjsOptions: {
include: [/node_modules/, /\/lib\/webmuxd\.js/, /\/lib\/core\/.*\.js/],
},
},
})