实现项目真机预览与待审版本扫码验收

需求:接管客户端未提交 WIP,保留真机预览,移除旧登录原型并维持 2.0.0。

实现:由 Main 核对项目与精确 Release,签发短时 Owner preview;补充一键提交映射能力及 Windows ZIP 预检兼容。
This commit is contained in:
2026-08-08 17:57:36 +08:00
parent 7b23cce67a
commit 1a19ad9808
20 changed files with 1890 additions and 12 deletions

View 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;
}

View File

@@ -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,

View File

@@ -393,7 +393,10 @@ async function readArchiveData(archivePath: string, entry: ArchiveEntry): Promis
const data = entry.getData();
if (data.byteLength > 0 || entry.header.size === 0) return data;
try {
const result = await execFileAsync('unzip', ['-p', archivePath, entry.entryName], { maxBuffer: MAX_SCANNED_TEXT_BYTES });
const command = process.platform === 'win32'
? { file: 'tar', args: ['-xOf', archivePath, entry.entryName] }
: { file: 'unzip', args: ['-p', archivePath, entry.entryName] };
const result = await execFileAsync(command.file, command.args, { maxBuffer: MAX_SCANNED_TEXT_BYTES });
return Buffer.from(result.stdout);
} catch (error) {
throw new Error(

View File

@@ -47,6 +47,14 @@ type DeploymentPatch = {
error?: string | null;
};
type SubmittedDeploymentInput = {
appId: string;
versionId: string;
versionName: string;
reviewStatus: string;
zipSha256?: string;
};
function normalizeApiBaseUrl(value: string): string {
const baseUrl = value.trim().replace(/\/+$/, '');
if (!/^https?:\/\//i.test(baseUrl)) {
@@ -253,6 +261,30 @@ export function createWorksCloudDeployment(
return record;
}
async function recordSubmitted(
projectId: string,
input: SubmittedDeploymentInput,
): Promise<WorksCloudDeploymentRecord> {
const project = (await projectStore.listProjects()).find((item) => item.id === projectId);
if (!project) throw new Error('Project not found');
const state = getOrCreateState(project, await readDeploymentRecord(project.path));
closeState(state);
const now = new Date().toISOString();
return await persist(state, {
schema_version: WORKS_CLOUD_DEPLOYMENT_SCHEMA_VERSION,
project_id: project.id,
status: 'submitted',
requested_at: now,
updated_at: now,
app_id: input.appId,
version_id: input.versionId,
version_name: input.versionName,
review_status: input.reviewStatus,
...(input.zipSha256 ? { zip_sha256: input.zipSha256 } : {}),
});
}
async function ensureRemoteProject(accessToken: string, publish: WorksPublishData): Promise<void> {
const headers = {
Authorization: `Bearer ${accessToken}`,
@@ -440,6 +472,7 @@ export function createWorksCloudDeployment(
return {
arm,
recordSubmitted,
get,
start,
stop,