收口 Makelore 客户端变更
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman
2026-08-12 22:35:06 +08:00
parent 253bad8b40
commit f4113a872f
232 changed files with 4617 additions and 20121 deletions

View File

@@ -70,6 +70,7 @@ type DesktopAuthTokenPayload = {
};
const DESKTOP_AUTH_TIMEOUT_MS = 10 * 60 * 1000;
const MAX_AUTH_ERROR_LENGTH = 180;
function readRequiredString(value: unknown, field: string): string {
if (typeof value !== 'string' || !value.trim()) {
@@ -164,17 +165,29 @@ async function readResponsePayload(response: Response): Promise<unknown> {
}
function getErrorMessage(payload: unknown, fallback: string): string {
const compact = (value: string): string => {
const normalized = value.replace(/\s+/g, ' ').trim();
if (
!normalized
|| normalized.length > MAX_AUTH_ERROR_LENGTH
|| /<!doctype\b|<html\b|<head\b|<body\b/i.test(normalized)
) {
return fallback;
}
return normalized;
};
if (payload && typeof payload === 'object') {
const record = payload as Record<string, unknown>;
for (const field of ['msg', 'message', 'error_description', 'error']) {
const value = record[field];
if (typeof value === 'string' && value.trim()) {
return value;
return compact(value);
}
}
}
if (typeof payload === 'string' && payload.trim()) {
return payload;
return compact(payload);
}
return fallback;
}
@@ -238,7 +251,7 @@ async function pollDesktopAuthToken(
const payload = await readResponsePayload(response) as DesktopAuthTokenPayload;
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Desktop authorization failed (${response.status})`));
throw new Error(getErrorMessage(payload, '登录授权失败,请稍后重试。'));
}
if (payload.status === 'approved' && payload.token) {
@@ -248,7 +261,7 @@ async function pollDesktopAuthToken(
await delay(pollIntervalMs);
}
throw new Error('Authorization timed out');
throw new Error('登录超时,请重新尝试。');
}
async function handleBrowserAuthorization(
@@ -264,7 +277,7 @@ async function handleBrowserAuthorization(
if (!response.ok) {
sendJson(res, response.status >= 400 && response.status < 500 ? response.status : 502, {
success: false,
error: getErrorMessage(payload, `Desktop authorization start failed (${response.status})`),
error: getErrorMessage(payload, '登录服务暂时不可用,请稍后重试。'),
});
return;
}

View File

@@ -1,407 +0,0 @@
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;
}

View File

@@ -24,7 +24,7 @@ import {
} from '../../opencode/playwright-mcp';
import { NIANCODE_USER_MODEL_ACCOUNT_ID } from '../../../shared/user-model-config';
import { listInstalledOpencodeSkills } from '../../opencode/skill-registry';
import { BUNDLED_COURSE_SKILL_IDS } from '../../opencode/superpowers';
import { BUNDLED_COURSE_SKILL_IDS } from '../../opencode/course-skills';
import { logger } from '../../utils/logger';
import { getProviderService } from '../../services/providers/provider-service';
import { getValidWorksSquareAccessToken } from '../../services/works-square-session';
@@ -41,6 +41,7 @@ import {
} from '../../opencode/project-conversations';
import {
completeProjectSession,
markProjectSessionRead,
removeProjectSessionMetadata,
patchProjectSessionMetadata,
upsertProjectSessionMetadata,
@@ -1017,16 +1018,8 @@ export async function handleOpencodeRoutes(
if (!body.projectId || !body.config) throw new Error('Missing project configuration');
const project = await findProjectById(ctx, body.projectId);
if (!project) throw new Error('Project not found');
const previousResult = await readProjectConfig(project.path);
const previousConfig = previousResult.status === 'valid' ? previousResult.config : null;
const config = await writeProjectConfig(project.path, body.config);
let status = ctx.opencodeManager.getStatus();
const runtimeSettingsChanged = !previousConfig
|| previousConfig.defaultModel !== config.defaultModel
|| previousConfig.superpowersEnabled !== config.superpowersEnabled;
if (config.initialized && runtimeSettingsChanged && status.state === 'running') {
status = await ctx.opencodeManager.restart();
}
const status = ctx.opencodeManager.getStatus();
sendJson(res, 200, { success: true, config, knowledgeFiles: await listProjectKnowledge(project.path), status });
} catch (error) {
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
@@ -1079,7 +1072,7 @@ export async function handleOpencodeRoutes(
nextState = removeProjectSessionMetadata(state, sessionId, now);
break;
case 'read':
nextState = patchProjectSessionMetadata(state, sessionId, { unreadCount: 0 }, now);
nextState = markProjectSessionRead(state, sessionId, now);
break;
case 'increment-unread': {
const current = state.sessions.find((item) => item.sessionId === sessionId);

View File

@@ -17,7 +17,6 @@ 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';
@@ -34,7 +33,6 @@ const coreRouteHandlers: RouteHandler[] = [
handleAuthRoutes,
handleImageWorkspaceRoutes,
handleWorksRoutes,
handleDevicePreviewRoutes,
handleAgentBrowserRoutes,
handleUserSyncRoutes,
handleOpencodeRoutes,