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 | null { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : null; } function readString(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null; } async function readResponsePayload(response: Response): Promise { 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 { const accessToken = await getValidWorksSquareAccessToken(); if (!accessToken) { throw new DevicePreviewRequestError('请先登录,再核对当前项目的真机预览版本', 401); } return accessToken; } async function fetchOwnedRemoteProject( appId: string, accessToken: string, ): Promise { 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 { 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 { 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 { 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 { 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; }