合并客户端真机预览链路
需求:将待审 Release 扫码验收与项目级真机入口并入登录、发布集成候选。 实现:合并 Main-owned 精确预览与提交映射能力,保留现有登录和一键发布边界。
This commit is contained in:
453
electron/api/routes/device-preview.ts
Normal file
453
electron/api/routes/device-preview.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
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;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { handleUsageRoutes } from './routes/usage';
|
||||
import { handleFileRoutes } from './routes/files';
|
||||
import { handleMeowaGameAssetsRoutes } from './routes/meowa-game-assets';
|
||||
import { handleAgentBrowserRoutes } from './routes/agent-browser';
|
||||
import { handleDevicePreviewRoutes } from './routes/device-preview';
|
||||
import { sendJson, setCorsHeaders, requireJsonContentType } from './route-utils';
|
||||
import { rotateRendererCapability } from './renderer-capability';
|
||||
|
||||
@@ -33,6 +34,7 @@ const coreRouteHandlers: RouteHandler[] = [
|
||||
handleAuthRoutes,
|
||||
handleImageWorkspaceRoutes,
|
||||
handleWorksRoutes,
|
||||
handleDevicePreviewRoutes,
|
||||
handleAgentBrowserRoutes,
|
||||
handleUserSyncRoutes,
|
||||
handleOpencodeRoutes,
|
||||
|
||||
Reference in New Issue
Block a user