diff --git a/.project-docs/30-worklog/tasks/20260808-device-preview-wip-c91e.md b/.project-docs/30-worklog/tasks/20260808-device-preview-wip-c91e.md new file mode 100644 index 0000000..aefa22b --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260808-device-preview-wip-c91e.md @@ -0,0 +1,46 @@ +# Task: 接管并整理客户端真机预览 WIP + +## Identity + +- Task ID: 20260808-device-preview-wip-c91e +- Mode: Feature +- Branch: codex/20260808-device-preview-wip-c91e-device-preview +- Worktree: D:\Datas\OthersProjects\makelore +- Base commit: 7b23cce67a1b9e61a48d89dedbd088d37b10ecd4 +- Owner: codex +- Status: Done + +## Scope + +- 接管原 `main` 工作区中已知未提交改动,收敛为独立真机预览功能。 +- 保留项目级入口、Main-owned 状态核对、二维码页面与测试;移除已被登录集成 `5792b07` 替代的旧认证尝试,并保持产品版本 `2.0.0`。 +- 对接待审 Release 的短时 Owner preview URL,同时保留已发布精确版本的同源 Runtime 预览。 + +## Intent And Constraints + +- 用户已明确授权接管这批已知 WIP,并确认登录实现以 `5792b07` 为准。 +- Renderer 只接收可展示的短期预览 URL,不接收账号 Token;Main 必须核对当前项目、app/version/release 身份、同源 HTTPS 与非回环地址。 +- 真机预览不新增顶层模块、发布上传工作台或云部署页面;版本号不从 `2.0.0` 回退。 + +## Outcome + +- 新增项目真机预览路由和二维码页;待审版本通过精确 Release 的 Owner preview ticket 验收,已发布版本使用核对过版本号的 Runtime。 +- 新增一键提交后的本地 app/version/review 映射写入能力,供集成阶段的发布成功响应调用。 +- Windows ZIP 预检改用系统自带 `tar` 作为条目读取兜底,测试 fixture 同步支持 Windows,不再要求额外安装 `zip/unzip`。 +- 旧登录刷新原型已从 WIP 中移除,包版本保持 `2.0.0`。 + +## Verification + +- `pnpm exec vitest run tests/unit/works-cloud-deployment.test.ts tests/unit/device-preview-routes.test.ts tests/unit/device-preview-page.test.tsx tests/unit/chat-device-preview-navigation.test.tsx tests/unit/agent-conversation-sidebar.test.tsx`:5 files / 57 tests passed。 +- `pnpm run typecheck`:passed。 +- scoped ESLint(本任务涉及的 Electron、Renderer 与测试文件):passed。 +- `git diff --check`:passed(仅工作区 CRLF 提示)。 + +## Follow-ups + +- Integration 阶段将本提交与登录 `5792b07`、一键发布 `724290e` 合并,并在一键提交成功后调用 `recordSubmitted` 写入精确 app/version/release 关联。 +- `opencode-chat-panel` 中依赖新登录续期实现的既有用例在本 Feature 基线上不单独修正,由登录集成覆盖后统一回归。 + +## Promotion Candidates + +- Integration 将真机预览的 Main-owned 安全边界和一键发布映射写入同步到 README 与当前状态文档;用户已确认产品方向。 diff --git a/README.md b/README.md index 38cacd0..5edee8f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Makelore 是一个面向软件与视觉创作的 AI 桌面工作台。当前版本为 `2.0.0`,包含两个已开通产品模块,并保留一个学习模块入口: -- `Makelore Code|AI 编程`:管理本地项目、项目 Agent、会话、文件上下文、代码变更和运行时。 +- `Makelore Code|AI 编程`:管理本地项目、项目 Agent、会话、文件上下文、代码变更和运行时,并为服务端可运行版本提供真机扫码预览。 - `Makelore Canvas|AI 绘画`:以设计项目(Workspace)组织 Agent 对话、方向确认、图片/视频生成任务和私有结果。 - `Makelore Learning|AI 学习`:数学与知识宇宙主题的学习入口,当前暂未开通;开屏页和模块切换菜单保留置灰入口,不能进入。 @@ -15,6 +15,7 @@ Makelore 是一个面向软件与视觉创作的 AI 桌面工作台。当前版 - 桌面技术栈:Electron、React 19、Vite、TypeScript、Zustand、Tailwind CSS。 - AI 编程运行时:Electron Main 管理项目内声明的 `opencode-ai` 依赖,Renderer 不直接启动或调用运行时。 - 共享开发浏览器:AI 编程右侧提供项目级浏览器,用户与 Agent 查看并调试同一实时页面、Console 和 Network,支持本地与公网开发地址。 +- 真机预览:AI 编程项目空间可读取当前服务端构建状态;可运行版本生成同源 HTTPS 二维码,供手机直接测试触控、屏幕和移动网络环境。 - 后端边界:Renderer 通过 Main 所有的 Host API 访问认证、模型、同步、更新、语音、图像与运行时能力。 - AI 绘画:每个设计项目固定一个设计 Agent。Agent 先通过持续对话收敛 Brief,用户确认服务端 Quote 后才创建图片或视频任务;任务进度通过 Agent Gateway 实时推送并在统一列表中展示,断流时使用低频 REST 同步。生产环境使用 Works Square 云端 Workspace 契约,上游不可用时明确报错。 - 视觉系统:单一浅色主题,品牌蓝 `#3A5578`、星火橙 `#F26A3D`、白色画布与低饱和蓝灰层级。 @@ -92,6 +93,12 @@ pnpm run package:linux - 用户和 Agent 操作同一个页面。Renderer 只负责显示、收起和布局;Agent 通过 Main 代理的页面级 CDP 工具导航、读取 Console/Network 和执行调试命令。 - 非 Web 协议、文件注入、跨目标及宿主级命令会被阻止。面板收起或被弹窗遮挡时隐藏原生页面并暂停 Agent 调试;该能力独立于发布和部署。 +### 真机预览 + +- 真机预览属于 Makelore Code 的项目级能力,不是新的顶层 AI 模块,也不复用 `/deliverables`,不恢复发布上传或云部署工作台。 +- Renderer 只读取 Main-owned Host API 的项目预览快照;Main 绑定当前项目、管理登录续签、核对目标版本,并只放行 Works Square 同源 HTTPS Runtime。二维码不包含账号 Token,也不生成本机回环地址。 +- 构建成功且进入待审的版本使用短时、只读、精确 Release 绑定的 Owner preview ticket;已发布版本可使用核对过版本号的同源 Runtime。构建中、旧版本或失败状态不会伪装成可扫码。 + ### 项目联系人与会话 - 新项目默认没有联系人或 Agent;用户在项目内手动创建联系人时必须填写名称、预设头像、职责和精确的 `provider/model`,提示词与 Skill 属于后置高级设置。 diff --git a/electron/api/routes/device-preview.ts b/electron/api/routes/device-preview.ts new file mode 100644 index 0000000..d6c8c5f --- /dev/null +++ b/electron/api/routes/device-preview.ts @@ -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 | 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; +} diff --git a/electron/api/server.ts b/electron/api/server.ts index b10e14f..96d1534 100644 --- a/electron/api/server.ts +++ b/electron/api/server.ts @@ -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, diff --git a/electron/opencode/works-square-deploy-check.ts b/electron/opencode/works-square-deploy-check.ts index 2c0e3d4..21691e8 100644 --- a/electron/opencode/works-square-deploy-check.ts +++ b/electron/opencode/works-square-deploy-check.ts @@ -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( diff --git a/electron/services/works-cloud-deployment.ts b/electron/services/works-cloud-deployment.ts index e25260c..2542b31 100644 --- a/electron/services/works-cloud-deployment.ts +++ b/electron/services/works-cloud-deployment.ts @@ -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 { + 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 { const headers = { Authorization: `Bearer ${accessToken}`, @@ -440,6 +472,7 @@ export function createWorksCloudDeployment( return { arm, + recordSubmitted, get, start, stop, diff --git a/package.json b/package.json index 02d8c79..3a9a466 100644 --- a/package.json +++ b/package.json @@ -93,10 +93,12 @@ "lru-cache": "^11.2.6", "monaco-editor": "^0.55.1", "ms": "^2.1.3", + "msgpackr": "2.0.5", "node-machine-id": "^1.1.12", "opencode-ai": "latest", "pdfjs-dist": "^5.7.284", "posthog-node": "^5.28.0", + "qrcode.react": "4.2.0", "rehype-katex": "^7.0.1", "remark-frontmatter": "^5.0.0", "remark-math": "^6.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dce8a2c..6542ec8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: ms: specifier: ^2.1.3 version: 2.1.3 + msgpackr: + specifier: 2.0.5 + version: 2.0.5 node-machine-id: specifier: ^1.1.12 version: 1.1.12 @@ -68,6 +71,9 @@ importers: posthog-node: specifier: ^5.28.0 version: 5.28.5 + qrcode.react: + specifier: 4.2.0 + version: 4.2.0(react@19.2.4) rehype-katex: specifier: ^7.0.1 version: 7.0.1 @@ -4227,6 +4233,11 @@ packages: resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==} hasBin: true + qrcode.react@4.2.0: + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + qs@6.15.0: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} @@ -9357,6 +9368,10 @@ snapshots: qrcode-terminal@0.12.0: {} + qrcode.react@4.2.0(react@19.2.4): + dependencies: + react: 19.2.4 + qs@6.15.0: dependencies: side-channel: 1.1.0 diff --git a/shared/device-preview.ts b/shared/device-preview.ts new file mode 100644 index 0000000..604a984 --- /dev/null +++ b/shared/device-preview.ts @@ -0,0 +1,13 @@ +export type DevicePreviewState = 'not_deployed' | 'building' | 'ready' | 'unavailable'; + +export type DevicePreviewSnapshot = { + state: DevicePreviewState; + projectId: string; + message: string; + appId?: string; + versionId?: string; + versionName?: string; + reviewStatus?: string; + launchUrl?: string; + updatedAt?: string; +}; diff --git a/src/App.tsx b/src/App.tsx index 6dae9a0..3d24148 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { PreviewScene } from './pages/Makelore'; import { ProjectConfiguration } from './pages/ProjectConfiguration'; import { Workbench } from './pages/Workbench'; import { ImageCanvas } from './pages/ImageCanvas'; +import { DevicePreview } from './pages/DevicePreview'; import { Settings } from './pages/Settings'; import { Setup } from './pages/Setup'; import { Login } from './pages/Login'; @@ -389,6 +390,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/opencode/AgentConversationSidebar.tsx b/src/components/opencode/AgentConversationSidebar.tsx index ad23a40..3474e35 100644 --- a/src/components/opencode/AgentConversationSidebar.tsx +++ b/src/components/opencode/AgentConversationSidebar.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo, useState } from 'react'; -import { Archive, ChevronRight, Pin, Plus, RotateCcw, Settings as SettingsIcon, Trash2 } from 'lucide-react'; +import { Archive, ChevronRight, Pin, Plus, RotateCcw, Settings as SettingsIcon, Smartphone, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -47,6 +47,7 @@ type AgentConversationSidebarProps = { onRestoreSession: (sessionId: string) => Promise; onDeleteSession: (sessionId: string) => Promise; onTogglePinAgent: (agent: ProjectAgentConfig) => Promise; + onOpenDevicePreview?: () => void; onOpenProjectSettings?: () => void; }; @@ -120,6 +121,7 @@ export function AgentConversationSidebar({ onRestoreSession, onDeleteSession, onTogglePinAgent, + onOpenDevicePreview, onOpenProjectSettings, }: AgentConversationSidebarProps) { const [createOpen, setCreateOpen] = useState(false); @@ -263,11 +265,16 @@ export function AgentConversationSidebar({ return (