408 lines
13 KiB
TypeScript
408 lines
13 KiB
TypeScript
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 {
|
||
trustedWorksProjectPlayUrl,
|
||
trustedWorksReleasePreviewUrl,
|
||
} from '../works-play-url';
|
||
import { getValidWorksSquareAccessToken } from '../../services/works-square-session';
|
||
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
||
import type { WorksSubmissionBindingRecord } from '../../../shared/works-submission-binding';
|
||
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.play_url) ?? readString(project.runtime_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}/`);
|
||
}
|
||
|
||
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: WorksSubmissionBindingRecord | 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 normalizedReviewStatus(value: string | null): string | null {
|
||
return value?.trim().toLowerCase() || null;
|
||
}
|
||
|
||
async function resolveDevicePreview(
|
||
projectId: string,
|
||
deployment: WorksSubmissionBindingRecord | 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 === 'legacy_retired') {
|
||
return {
|
||
...base,
|
||
state: 'unavailable',
|
||
message: deployment.message || '旧版自动部署任务已停用,请重新提交审核。',
|
||
};
|
||
}
|
||
|
||
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
|
||
? trustedWorksProjectPlayUrl(
|
||
remote.runtimeUrl,
|
||
normalizedWorksBaseUrl(),
|
||
deployment.app_id,
|
||
)
|
||
: 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 = trustedWorksReleasePreviewUrl(
|
||
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.worksSubmissionBinding
|
||
? await ctx.worksSubmissionBinding.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;
|
||
}
|