Files
makelore/electron/api/routes/device-preview.ts
brother7 1a19ad9808 实现项目真机预览与待审版本扫码验收
需求:接管客户端未提交 WIP,保留真机预览,移除旧登录原型并维持 2.0.0。

实现:由 Main 核对项目与精确 Release,签发短时 Owner preview;补充一键提交映射能力及 Windows ZIP 预检兼容。
2026-08-08 17:57:36 +08:00

454 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { IncomingMessage, ServerResponse } from 'node:http';
import type { HostApiContext } from '../context';
import { sendJson } from '../route-utils';
import { hasRendererCapability } from '../renderer-capability';
import { WORKS_SQUARE_CONFIG } from '../works-config';
import { getValidWorksSquareAccessToken } from '../../services/works-square-session';
import { proxyAwareFetch } from '../../utils/proxy-fetch';
import type { WorksCloudDeploymentRecord } from '../../../shared/works-cloud-deployment';
import type { DevicePreviewSnapshot } from '../../../shared/device-preview';
type RemoteProjectSnapshot = {
appId: string | null;
playable: boolean | null;
runtimeUrl: string | null;
runtimeVersionName: string | null;
latestVersionId: string | null;
latestVersionName: string | null;
latestReleaseId: string | null;
reviewStatus: string | null;
};
class DevicePreviewRequestError extends Error {
readonly statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.name = 'DevicePreviewRequestError';
this.statusCode = statusCode;
}
}
const DEVICE_PREVIEW_ROUTE = /^\/api\/opencode\/projects\/([^/]+)\/device-preview$/;
const OWNER_STATUS_TIMEOUT_MS = 10_000;
const READY_REVIEW_STATUSES = new Set(['approved', 'published']);
const FAILED_REVIEW_STATUSES = new Set(['blocked', 'failed', 'rejected']);
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function readString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
async function readResponsePayload(response: Response): Promise<unknown> {
const text = await response.text();
if (!text.trim()) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return null;
}
}
function projectFromPayload(payload: unknown): RemoteProjectSnapshot | null {
const root = asRecord(payload);
if (!root) return null;
const wrappedStatus = asRecord(root.status);
const project = asRecord(root.project)
?? asRecord(wrappedStatus?.project)
?? (readString(root.app_id) ? root : null);
if (!project) return null;
const latestVersion = asRecord(root.latest_version) ?? asRecord(wrappedStatus?.latest_version);
return {
appId: readString(project.app_id),
playable: typeof project.playable === 'boolean' ? project.playable : null,
runtimeUrl: readString(project.runtime_url) ?? readString(project.play_url),
runtimeVersionName: readString(project.version_name),
latestVersionId: readString(latestVersion?.id) ?? readString(latestVersion?.version_id),
latestVersionName: readString(latestVersion?.version_name),
latestReleaseId: readString(latestVersion?.release_id),
reviewStatus: readString(latestVersion?.review_status),
};
}
function normalizedWorksBaseUrl(): URL {
const base = WORKS_SQUARE_CONFIG.apiBaseUrl.trim().replace(/\/+$/, '');
return new URL(`${base}/`);
}
function isLoopbackHostname(hostname: string): boolean {
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '');
return normalized === 'localhost'
|| normalized === '::1'
|| normalized === '0.0.0.0'
|| normalized.startsWith('127.');
}
function trustedLaunchUrl(value: string, worksBase: URL): string | null {
try {
const target = new URL(value, worksBase);
const absoluteUrl = target.toString();
if (
absoluteUrl.length > 1_024
|| worksBase.protocol !== 'https:'
|| target.protocol !== 'https:'
|| target.origin !== worksBase.origin
|| Boolean(target.username || target.password)
|| isLoopbackHostname(target.hostname)
) {
return null;
}
return absoluteUrl;
} catch {
return null;
}
}
function trustedReleasePreviewUrl(
value: string,
worksBase: URL,
releaseId: string,
): string | null {
const trusted = trustedLaunchUrl(value, worksBase);
if (!trusted) return null;
const target = new URL(trusted);
const expectedPrefix = `/previews/${encodeURIComponent(releaseId)}/`;
return target.pathname.startsWith(expectedPrefix) ? trusted : null;
}
async function requireManagedAccessToken(): Promise<string> {
const accessToken = await getValidWorksSquareAccessToken();
if (!accessToken) {
throw new DevicePreviewRequestError('请先登录,再核对当前项目的真机预览版本', 401);
}
return accessToken;
}
async function fetchOwnedRemoteProject(
appId: string,
accessToken: string,
): Promise<RemoteProjectSnapshot | null> {
const worksBase = normalizedWorksBaseUrl();
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), OWNER_STATUS_TIMEOUT_MS);
let response: Response;
try {
response = await proxyAwareFetch(
new URL(`/api/projects/mine/${encodeURIComponent(appId)}/status`, worksBase).toString(),
{
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
signal: abortController.signal,
},
);
} catch (error) {
if (abortController.signal.aborted) {
throw new DevicePreviewRequestError('服务端预览状态读取超时,请稍后刷新', 502);
}
throw error;
} finally {
clearTimeout(timeout);
}
if (response.status === 404) return null;
if (response.status === 401 || response.status === 403) {
throw new DevicePreviewRequestError('登录状态已失效,请重新登录后刷新预览', response.status);
}
if (!response.ok) {
throw new DevicePreviewRequestError(`服务端预览状态读取失败(${response.status}`, 502);
}
const project = projectFromPayload(await readResponsePayload(response));
if (!project) {
throw new DevicePreviewRequestError('服务端返回了无法识别的预览状态', 502);
}
return project;
}
async function createOwnedReleasePreview(
appId: string,
releaseId: string,
accessToken: string,
): Promise<string> {
const worksBase = normalizedWorksBaseUrl();
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), OWNER_STATUS_TIMEOUT_MS);
let response: Response;
try {
response = await proxyAwareFetch(
new URL(
`/api/projects/${encodeURIComponent(appId)}/releases/${encodeURIComponent(releaseId)}/preview-url`,
worksBase,
).toString(),
{
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
signal: abortController.signal,
},
);
} catch (error) {
if (abortController.signal.aborted) {
throw new DevicePreviewRequestError('服务端预览地址生成超时,请稍后刷新', 502);
}
throw error;
} finally {
clearTimeout(timeout);
}
if (response.status === 401 || response.status === 403) {
throw new DevicePreviewRequestError('登录状态已失效,请重新登录后刷新预览', response.status);
}
if (!response.ok) {
throw new DevicePreviewRequestError(`服务端预览地址生成失败(${response.status}`, 502);
}
const payload = asRecord(await readResponsePayload(response));
const previewUrl = readString(payload?.url);
if (!previewUrl) {
throw new DevicePreviewRequestError('服务端返回了无法识别的预览地址', 502);
}
return previewUrl;
}
function localSnapshot(
projectId: string,
deployment: WorksCloudDeploymentRecord | null,
): Omit<DevicePreviewSnapshot, 'state' | 'message'> {
return {
projectId,
...(deployment?.app_id ? { appId: deployment.app_id } : {}),
...(deployment?.version_id ? { versionId: deployment.version_id } : {}),
...(deployment?.version_name ? { versionName: deployment.version_name } : {}),
...(deployment?.review_status ? { reviewStatus: deployment.review_status } : {}),
...(deployment?.updated_at ? { updatedAt: deployment.updated_at } : {}),
};
}
function buildingMessage(status: WorksCloudDeploymentRecord['status']): string {
if (status === 'waiting_for_login') return '正在等待登录状态恢复,恢复后会继续提交预览版本。';
if (status === 'uploading') return '预览包正在上传到服务端。';
return '正在等待新的预览包与安全检查完成。';
}
function normalizedReviewStatus(value: string | null): string | null {
return value?.trim().toLowerCase() || null;
}
async function resolveDevicePreview(
projectId: string,
deployment: WorksCloudDeploymentRecord | null,
): Promise<DevicePreviewSnapshot> {
if (!deployment) {
return {
projectId,
state: 'not_deployed',
message: '当前项目还没有与本机绑定的服务端预览版本。',
};
}
if (deployment.project_id !== projectId) {
return {
projectId,
state: 'unavailable',
message: '本地预览记录不属于当前项目,请重新生成。',
};
}
const base = localSnapshot(projectId, deployment);
if (deployment.status === 'failed') {
return {
...base,
state: 'unavailable',
message: deployment.error || '预览版本生成失败,请重新生成。',
};
}
if (deployment.status !== 'submitted') {
return {
...base,
state: 'building',
message: buildingMessage(deployment.status),
};
}
if (!deployment.app_id || !deployment.version_id || !deployment.version_name) {
return {
...base,
state: 'unavailable',
message: '本地预览记录缺少版本标识,请重新生成。',
};
}
const accessToken = await requireManagedAccessToken();
const remote = await fetchOwnedRemoteProject(deployment.app_id, accessToken);
if (!remote) {
return {
...base,
state: 'building',
message: '服务端正在接收或构建本次预览版本。',
};
}
if (remote.appId !== deployment.app_id) {
return {
...base,
state: 'unavailable',
message: '服务端返回的项目标识不匹配,已停止展示预览。',
};
}
const reviewStatus = normalizedReviewStatus(remote.reviewStatus);
const remoteBase = {
...base,
...(remote.reviewStatus ? { reviewStatus: remote.reviewStatus } : {}),
};
const targetVersionMatches = remote.latestVersionId === deployment.version_id
&& remote.latestVersionName === deployment.version_name;
const launchUrl = remote.runtimeUrl
? trustedLaunchUrl(remote.runtimeUrl, normalizedWorksBaseUrl())
: null;
if (!targetVersionMatches) {
return {
...remoteBase,
state: 'unavailable',
message: '服务端最新版本已与本地绑定版本不一致,请重新同步项目状态。',
};
}
if (reviewStatus && FAILED_REVIEW_STATUSES.has(reviewStatus)) {
return {
...remoteBase,
state: 'unavailable',
message: '本次预览版本未通过服务端检查,请修改后重新生成。',
};
}
if (reviewStatus === 'building' || reviewStatus === 'queued' || reviewStatus === 'reviewing') {
return {
...remoteBase,
state: 'building',
message: '本次预览版本仍在构建或审核,暂不展示旧版本。',
};
}
if (reviewStatus === 'pending_review') {
if (!remote.latestReleaseId) {
return {
...remoteBase,
state: 'building',
message: '本次版本已构建完成,正在准备审核前预览。',
};
}
const rawPreviewUrl = await createOwnedReleasePreview(
deployment.app_id,
remote.latestReleaseId,
accessToken,
);
const previewUrl = trustedReleasePreviewUrl(
rawPreviewUrl,
normalizedWorksBaseUrl(),
remote.latestReleaseId,
);
if (!previewUrl) {
return {
...remoteBase,
state: 'unavailable',
message: '服务端返回的审核前预览地址未通过安全校验。',
};
}
return {
...remoteBase,
state: 'ready',
launchUrl: previewUrl,
message: '审核前真机预览已就绪。',
};
}
if (remote.runtimeUrl && !launchUrl) {
return {
...remoteBase,
state: 'unavailable',
message: '服务端返回的预览地址未通过安全校验。',
};
}
if (
remote.playable === true
&& launchUrl
&& remote.runtimeVersionName === deployment.version_name
&& reviewStatus !== null
&& READY_REVIEW_STATUSES.has(reviewStatus)
) {
return {
...remoteBase,
state: 'ready',
launchUrl,
message: '真机预览已就绪。',
};
}
if (
reviewStatus !== null
&& READY_REVIEW_STATUSES.has(reviewStatus)
&& remote.runtimeVersionName !== deployment.version_name
) {
return {
...remoteBase,
state: 'building',
message: '本次版本已通过审核,服务端运行地址仍在切换中。',
};
}
return {
...remoteBase,
state: 'unavailable',
message: '服务端尚未确认本次版本可供真机访问。',
};
}
export async function handleDevicePreviewRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
const match = url.pathname.match(DEVICE_PREVIEW_ROUTE);
if (!match) return false;
res.setHeader('Cache-Control', 'no-store');
if (req.method !== 'GET') {
res.setHeader('Allow', 'GET');
sendJson(res, 405, { success: false, error: 'Method not allowed' });
return true;
}
if (!hasRendererCapability(req)) {
sendJson(res, 403, { success: false, error: 'Renderer capability required' });
return true;
}
try {
const projectId = decodeURIComponent(match[1] ?? '').trim();
const activeProject = await ctx.opencodeProjectStore.getActiveProject();
if (!projectId || !activeProject || activeProject.id !== projectId) {
sendJson(res, 409, { success: false, error: 'Select this project before opening device preview' });
return true;
}
const deployment = ctx.worksCloudDeployment
? await ctx.worksCloudDeployment.get(projectId)
: null;
const preview = await resolveDevicePreview(projectId, deployment);
sendJson(res, 200, { success: true, preview });
} catch (error) {
sendJson(res, error instanceof DevicePreviewRequestError ? error.statusCode : 502, {
success: false,
error: error instanceof Error ? error.message : String(error),
});
}
return true;
}