This commit is contained in:
user
2026-03-27 20:06:38 +02:00
commit 8c45efc92e
544 changed files with 33060 additions and 0 deletions

33
apps/front/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "@apps/front",
"type": "module",
"scripts": {
"dev": "PORT=3000 tsx watch src/index.ts",
"build": "tsc",
"prod": "HOST=0.0.0.0 PORT=3000 tsx src/index.ts"
},
"dependencies": {
"@hono/node-server": "^1.19.9",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.70.1",
"@opentelemetry/exporter-logs-otlp-proto": "^0.212.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.212.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.212.0",
"@opentelemetry/sdk-logs": "^0.212.0",
"@opentelemetry/sdk-metrics": "^2.1.0",
"@opentelemetry/sdk-node": "^0.212.0",
"@pkg/db": "workspace:*",
"@pkg/logger": "workspace:*",
"@pkg/logic": "workspace:*",
"@pkg/result": "workspace:*",
"@pkg/settings": "workspace:*",
"hono": "^4.12.8",
"import-in-the-middle": "^3.0.0",
"valibot": "^1.2.0"
},
"devDependencies": {
"@types/node": "^25.3.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}

153
apps/front/src/index.ts Normal file
View File

@@ -0,0 +1,153 @@
import "./instrumentation.js";
import { createHttpTelemetryMiddleware } from "@pkg/logic/core/http.telemetry";
import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context";
import { logDomainEvent } from "@pkg/logger";
import { serve } from "@hono/node-server";
import { settings } from "@pkg/settings";
import { randomUUID } from "node:crypto";
import { Hono } from "hono";
const app = new Hono().use("*", createHttpTelemetryMiddleware("front"));
const host = process.env.HOST || "0.0.0.0";
const port = Number(process.env.PORT || "3000");
function normalizeBaseUrl(url: string): string {
return url.endsWith("/") ? url.slice(0, -1) : url;
}
function buildFlowExecCtx(): FlowExecCtx {
return { flowId: randomUUID() };
}
function getClientDownloadedApkName(): string {
const filename = settings.clientDownloadedApkName.trim();
return filename.toLowerCase().endsWith(".apk")
? filename
: `${filename}.apk`;
}
app.get("/health", (c) => {
return c.json({ ok: true });
});
app.get("/ping", (c) => {
return c.text("pong");
});
app.get("/downloads/file/:buildId", async (c) => {
const fctx = buildFlowExecCtx();
const buildId = c.req.param("buildId");
logDomainEvent({
event: "processor.apk_download.started",
fctx,
meta: { buildId },
});
const buildResult = await mobileBuildController.validateActiveBuildId(
fctx,
buildId,
);
if (buildResult.isErr()) {
logDomainEvent({
level: "warn",
event: "processor.apk_download.rejected",
fctx,
error: buildResult.error,
meta: { buildId },
});
return c.json(
{
data: null,
error: { ...buildResult.error, flowId: fctx.flowId },
},
404,
);
}
const build = buildResult.value;
if (!build.apkAssetPath) {
logDomainEvent({
level: "warn",
event: "processor.apk_download.missing_artifact",
fctx,
meta: { buildId },
});
return c.json(
{
data: null,
error: {
flowId: fctx.flowId,
code: "NOT_FOUND",
message: "APK not available",
description: "This build does not have a generated APK yet",
detail: `buildId=${buildId}`,
},
},
404,
);
}
const assetUrl = `${normalizeBaseUrl(settings.appBuilderApiUrl)}${build.apkAssetPath}`;
const assetResponse = await fetch(assetUrl);
if (!assetResponse.ok || !assetResponse.body) {
logDomainEvent({
level: "error",
event: "processor.apk_download.fetch_failed",
fctx,
meta: {
buildId,
assetUrl,
status: assetResponse.status,
},
});
return c.json(
{
data: null,
error: {
flowId: fctx.flowId,
code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch APK artifact",
description: "Please try again later",
detail: `assetUrl=${assetUrl} status=${assetResponse.status}`,
},
},
502,
);
}
logDomainEvent({
event: "processor.apk_download.succeeded",
fctx,
meta: {
buildId,
assetUrl,
downloadName: getClientDownloadedApkName(),
},
});
return new Response(assetResponse.body, {
status: 200,
headers: {
"content-type":
assetResponse.headers.get("content-type") ||
"application/vnd.android.package-archive",
"content-disposition": `attachment; filename="${getClientDownloadedApkName()}"`,
"cache-control": "no-store",
},
});
});
serve(
{
fetch: app.fetch,
port,
hostname: host,
},
(info) => {
console.log(`Server is running on http://${host}:${info.port}`);
},
);

View File

@@ -0,0 +1,50 @@
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
import { createAddHookMessageChannel } from "import-in-the-middle";
import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { settings } from "@pkg/settings";
import { register } from "node:module";
const { registerOptions } = createAddHookMessageChannel();
register("import-in-the-middle/hook.mjs", import.meta.url, registerOptions);
const normalizedEndpoint = settings.otelExporterOtlpHttpEndpoint.startsWith(
"http",
)
? settings.otelExporterOtlpHttpEndpoint
: `http://${settings.otelExporterOtlpHttpEndpoint}`;
if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = normalizedEndpoint;
}
const sdk = new NodeSDK({
serviceName: `${settings.otelServiceName}-processor`,
traceExporter: new OTLPTraceExporter(),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter(),
exportIntervalMillis: 10_000,
}),
logRecordProcessors: [new BatchLogRecordProcessor(new OTLPLogExporter())],
instrumentations: [
getNodeAutoInstrumentations({
"@opentelemetry/instrumentation-winston": {
// We add OpenTelemetryTransportV3 explicitly in @pkg/logger.
disableLogSending: true,
},
}),
],
});
sdk.start();
const shutdown = () => {
void sdk.shutdown();
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

33
apps/front/tsconfig.json Normal file
View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"verbatimModuleSyntax": false,
"skipLibCheck": true,
"types": [
"node"
],
"baseUrl": ".",
"paths": {
"@pkg/logic": ["../../packages/logic"],
"@pkg/logic/*": ["../../packages/logic/*"],
"@pkg/db": ["../../packages/db"],
"@pkg/db/*": ["../../packages/db/*"],
"@pkg/logger": ["../../packages/logger"],
"@pkg/logger/*": ["../../packages/logger/*"],
"@pkg/result": ["../../packages/result"],
"@pkg/result/*": ["../../packages/result/*"],
"@pkg/settings": ["../../packages/settings"],
"@pkg/settings/*": ["../../packages/settings/*"],
"@/*": ["../../packages/logic/*"],
"@core/*": ["../../packages/logic/core/*"],
"@domains/*": ["../../packages/logic/domains/*"]
},
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"outDir": "./dist"
},
"exclude": ["node_modules"]
}

290
apps/front/view.html Normal file
View File

@@ -0,0 +1,290 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<!-- iOS full-screen web app -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<meta name="mobile-web-app-capable" content="yes" />
<title>Loading...</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body,
html {
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
overscroll-behavior: none;
touch-action: none;
-webkit-user-select: none;
user-select: none;
}
/* ── Loading overlay ─────────────────────────── */
#overlay {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #000;
color: #999;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
z-index: 200;
transition: opacity 0.4s ease;
}
#overlay.hidden {
opacity: 0;
pointer-events: none;
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid #333;
border-top-color: #999;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ── ws-scrcpy iframe ────────────────────────── */
#scrcpy-frame {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
</style>
</head>
<body>
<div id="overlay">
<div class="spinner"></div>
<span>Connecting...</span>
</div>
<iframe id="scrcpy-frame" src="" allow="autoplay"></iframe>
<script>
const iframe = document.getElementById("scrcpy-frame");
const overlay = document.getElementById("overlay");
// We need the device UDID to build the direct-stream URL.
// Strategy: first, load the ws-scrcpy device list page silently
// and scrape the first available device, then redirect the iframe
// to the stream URL for that device.
// Phase 1: load device list from ws-scrcpy (proxied through us)
async function getFirstDevice() {
// ws-scrcpy exposes device info via its tracker WebSocket,
// but the simplest approach is to fetch the page HTML and
// parse the device list. However, ws-scrcpy is an SPA that
// builds the list client-side. So instead we'll use a short
// poll: load ws-scrcpy in the iframe with the device list,
// wait for a device to appear, grab its UDID, then switch
// to the stream view.
// Actually, even simpler: ws-scrcpy also has a device tracker
// that sends device info over WebSocket. Let's just load the
// ws-scrcpy index in the iframe, wait for the content, then
// inject CSS + auto-click the first stream player link.
// Load ws-scrcpy's index (proxied, so same-origin)
// The ?scrcpy=1 param tells our Bun proxy to pass through
// to ws-scrcpy instead of serving view.html again.
iframe.src = "/?scrcpy=1";
}
const HIDE_CSS = `
/* Hide device list table and all non-video UI */
#devices,
.table-wrapper,
.tracker-name,
.control-buttons-list,
.control-wrapper,
.more-box {
display: none !important;
}
/* Make video fill the entire viewport */
.device-view {
position: fixed !important;
inset: 0 !important;
width: 100% !important;
height: 100% !important;
margin: 0 !important;
padding: 0 !important;
background: #000 !important;
}
.video {
width: 100% !important;
height: 100% !important;
position: absolute !important;
inset: 0 !important;
}
.video-layer,
.touch-layer {
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 100% !important;
object-fit: contain !important;
}
body {
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
background: #000 !important;
}
`;
function injectCSS(doc) {
if (doc.querySelector("#maitm-hide-css")) return;
const style = doc.createElement("style");
style.id = "maitm-hide-css";
style.textContent = HIDE_CSS;
doc.head.appendChild(style);
}
// Phase 2: once iframe loads, inject CSS to hide the device list
// UI and auto-click the first available stream link.
iframe.addEventListener("load", function onLoad() {
try {
const doc =
iframe.contentDocument || iframe.contentWindow.document;
if (!doc) return;
injectCSS(doc);
// Re-inject CSS whenever ws-scrcpy rebuilds the DOM
// (e.g., when navigating from device list to stream via hash)
const observer = new MutationObserver(() => injectCSS(doc));
observer.observe(doc.body, {
childList: true,
subtree: true,
});
// Now we need to auto-navigate to the first device's stream.
// The device list is built async via WebSocket, so we poll
// for a device link to appear and click it.
pollForDevice(doc);
} catch (e) {
console.error("Cannot access iframe (cross-origin?):", e);
}
});
function pollForDevice(doc) {
let attempts = 0;
const maxAttempts = 60; // 30 seconds
const interval = setInterval(() => {
attempts++;
// Look for stream player links in the device list
// ws-scrcpy renders links with player names as link text
const links = doc.querySelectorAll("a");
let streamLink = null;
for (const link of links) {
const href = link.getAttribute("href") || "";
const text = (link.textContent || "").toLowerCase();
// Look for player links — they contain "action=stream"
// or player names like "mse", "webcodecs", etc.
if (
href.includes("action=stream") ||
text === "mse" ||
text === "webcodecs" ||
text === "tinyh264" ||
text === "broadway"
) {
streamLink = link;
break;
}
}
if (streamLink) {
clearInterval(interval);
console.log(
"Found device stream link, clicking:",
streamLink.href,
);
streamLink.click();
// Wait for stream to start rendering, then hide overlay
waitForVideo(doc);
return;
}
if (attempts >= maxAttempts) {
clearInterval(interval);
overlay.querySelector("span").textContent =
"No device found. Make sure your phone is connected via USB.";
}
}, 500);
}
function waitForVideo(doc) {
let checks = 0;
const interval = setInterval(() => {
checks++;
const canvas = doc.querySelector("canvas");
const video = doc.querySelector("video");
if (canvas || video) {
clearInterval(interval);
// Give a moment for first frame to render
setTimeout(() => {
overlay.classList.add("hidden");
document.title = "\u200B"; // Zero-width space — blank title
}, 500);
return;
}
if (checks > 30) {
clearInterval(interval);
overlay.classList.add("hidden");
}
}, 300);
}
// Prevent default touch behaviors that interfere
document.addEventListener("touchmove", (e) => e.preventDefault(), {
passive: false,
});
document.addEventListener(
"touchstart",
(e) => {
if (e.touches.length > 1) e.preventDefault();
},
{ passive: false },
);
// Start
getFirstDevice();
</script>
</body>
</html>