minecodex 2.0.1 → 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +149 -0
- package/apps/dashboard/DESIGN.md +45 -0
- package/features/images/DESIGN.md +71 -0
- package/features/images/README.md +2 -0
- package/features/images/codex-feature.json +2 -1
- package/features/images/src/adapters/codex/cloud-import.mjs +52 -0
- package/features/images/src/adapters/codex/image-prompt.mjs +22 -0
- package/features/images/src/adapters/codex/library-api.mjs +55 -0
- package/features/images/src/adapters/codex/library-viewer.mjs +18 -0
- package/features/images/src/adapters/codex/prompt-resolver-source.mjs +8 -0
- package/features/images/src/backend/cloud-library.mjs +10 -0
- package/features/images/src/domain/library-prompt.mjs +7 -0
- package/features/images/src/library-prompt-page.mjs +8 -76
- package/features/images/src/library-prompt-source.mjs +10 -0
- package/features/images/web/app.js +9 -9
- package/features/images/web/styles.css +219 -219
- package/features/model-slider/DESIGN.md +34 -0
- package/features/model-slider/src/adapters/codex/controller.mjs +93 -0
- package/features/model-slider/src/domain/model-rules.mjs +143 -0
- package/features/model-slider/src/frontend/install.mjs +2110 -0
- package/features/model-slider/src/injection-source.mjs +10 -0
- package/features/notes/DESIGN.md +19 -15
- package/features/notes/README.md +10 -2
- package/features/notes/assets/default-prompts.json +170 -0
- package/features/notes/assets/dsh-prompt-LICENSE.txt +24 -0
- package/features/notes/assets/icons/book-open-nav-fill.svg +1 -0
- package/features/notes/codex-feature.json +5 -1
- package/features/notes/src/backend/default-prompts.mjs +29 -0
- package/features/notes/src/{product.mjs → backend/product.mjs} +3 -1
- package/features/notes/src/{quick-prompts-server.mjs → backend/quick-prompts-server.mjs} +2 -2
- package/features/notes/src/{quick-prompts-page.mjs → frontend/entry.mjs} +78 -266
- package/features/notes/src/frontend/icons.json +13 -0
- package/features/notes/src/frontend/styles.css +110 -0
- package/features/notes/src/main.mjs +2 -2
- package/features/notes/vendor/phosphor-LICENSE +21 -0
- package/features/session-status/DESIGN.md +7 -7
- package/features/session-status/README.md +4 -0
- package/features/session-status/codex-feature.json +1 -1
- package/features/session-status/src/adapters/codex/context-usage.mjs +39 -0
- package/features/session-status/src/adapters/codex/session-log.mjs +81 -0
- package/features/session-status/src/adapters/codex/session-reader.mjs +106 -0
- package/features/session-status/src/domain/session-stats.mjs +141 -0
- package/features/session-status/src/injection-source.mjs +6 -0
- package/features/session-status/src/session-status.mjs +43 -103
- package/features/sidebar-focus/DESIGN.md +6 -5
- package/features/sidebar-focus/codex-feature.json +2 -1
- package/features/sidebar-focus/src/adapters/codex/renderer.mjs +28 -0
- package/features/sidebar-focus/src/injection-source.mjs +6 -0
- package/features/sidebar-focus/src/sidebar-focus.mjs +19 -66
- package/features/sites/DESIGN.md +60 -0
- package/features/sites/README.md +27 -0
- package/features/sites/codex-feature.json +28 -0
- package/features/sites/src/adapters/codex/catalog.mjs +26 -0
- package/features/sites/src/adapters/codex/renderer.mjs +83 -0
- package/features/sites/src/backend/discovery.mjs +73 -0
- package/features/sites/src/backend/project-sites.mjs +34 -0
- package/features/sites/src/backend/site-launcher.mjs +54 -0
- package/features/sites/src/backend/sites-preview.mjs +53 -0
- package/features/sites/src/backend/sites-service.mjs +162 -0
- package/features/sites/src/domain/site.mjs +40 -0
- package/features/sites/src/frontend/entry.mjs +383 -0
- package/features/sites/src/frontend/icons.json +12 -0
- package/features/sites/src/frontend/styles.css +72 -0
- package/features/sites/src/injection-source.mjs +7 -0
- package/features/sites/vendor/lucide-LICENSE +35 -0
- package/features/unread-inbox/DESIGN.md +6 -2
- package/features/unread-inbox/codex-feature.json +2 -1
- package/features/unread-inbox/src/adapters/codex/renderer.mjs +66 -0
- package/features/unread-inbox/src/injection-source.mjs +6 -0
- package/features/unread-inbox/src/unread-inbox.mjs +36 -91
- package/features/usage-meter/DESIGN.md +9 -9
- package/features/usage-meter/README.md +2 -1
- package/features/usage-meter/codex-feature.json +3 -3
- package/features/usage-meter/src/adapters/codex/renderer.mjs +61 -0
- package/features/usage-meter/src/injection-source.mjs +6 -0
- package/features/usage-meter/src/usage-meter.mjs +27 -66
- package/package.json +17 -4
- package/packages/cli/src/config.mjs +2 -1
- package/packages/codex-adapter/README.md +15 -0
- package/packages/codex-adapter/package.json +1 -0
- package/packages/codex-adapter/src/renderer/environment.mjs +53 -0
- package/packages/runtime-host/README.md +27 -0
- package/packages/runtime-host/src/codex-injection.mjs +36 -2393
- package/packages/runtime-host/src/codex-runtime.mjs +21 -3
- package/packages/runtime-host/src/feature-registry.mjs +28 -3
- package/packages/ui/DESIGN.md +31 -0
- package/packages/ui/package.json +8 -0
- package/packages/ui/src/components/button.mjs +21 -0
- package/packages/ui/src/components/composer-menu.mjs +18 -0
- package/packages/ui/src/components/dialog.mjs +55 -0
- package/packages/ui/src/components/navigation-rail.mjs +77 -0
- package/packages/ui/src/components/page.mjs +8 -0
- package/packages/ui/src/components/tooltip.mjs +96 -0
- package/packages/ui/src/foundation/layers.mjs +14 -0
- package/packages/ui/src/foundation/surfaces.mjs +34 -0
- package/packages/ui/src/install.mjs +22 -0
- package/packages/ui/src/native/navigation-rail.mjs +33 -0
- package/packages/ui/src/native/theme.mjs +35 -0
- package/packages/{runtime-host/src/codex-design-contract.mjs → ui/src/native/tokens.mjs} +67 -24
- package/packages/runtime-host/src/cloud-images.mjs +0 -123
- package/packages/runtime-host/src/session-status-reader.mjs +0 -340
- /package/features/notes/src/{prompt-schema.mjs → backend/prompt-schema.mjs} +0 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
// Codex 的项目目录与数据库结构在此转换为插件目录快照。
|
|
5
|
+
export class CodexCatalog {
|
|
6
|
+
constructor(codexHome) { this.codexHome=codexHome; this.status={available:false,reason:"not-read"}; }
|
|
7
|
+
diagnostics() {return {...this.status};}
|
|
8
|
+
async snapshot() {
|
|
9
|
+
let state;
|
|
10
|
+
try { state = JSON.parse(await readFile(path.join(this.codexHome, ".codex-global-state.json"), "utf8")); }
|
|
11
|
+
catch (error) { if (error.code === "ENOENT") {this.status={available:false,reason:"catalog-state-unavailable"};return { projects: [], threads: [] };} throw error; }
|
|
12
|
+
this.status={available:true,reason:null};
|
|
13
|
+
const projects = Object.entries(state["local-projects"] ?? {}).map(([id, p]) => ({ id, name: p.name || id, rootPaths: (p.rootPaths ?? []).filter(r => typeof r === "string") }));
|
|
14
|
+
const ids = new Set(state["projectless-thread-ids"] ?? []);
|
|
15
|
+
let threads = [];
|
|
16
|
+
if (ids.size) {
|
|
17
|
+
let db;
|
|
18
|
+
try {
|
|
19
|
+
db = new DatabaseSync(path.join(this.codexHome, "state_5.sqlite"), { readOnly: true });
|
|
20
|
+
threads = db.prepare("SELECT id, title, name FROM threads WHERE archived = 0 ORDER BY updated_at DESC").all().filter(t => ids.has(t.id)).map(t => ({ id: t.id, title: t.name || t.title || t.id }));
|
|
21
|
+
} catch (error) { if (!/unable to open database/i.test(error.message)) throw error; this.status={available:false,reason:"thread-database-unavailable"}; }
|
|
22
|
+
finally { db?.close(); }
|
|
23
|
+
}
|
|
24
|
+
return { projects, threads };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// 专有路由、页面锚点和原生网页打开协议不跨出此适配器。
|
|
2
|
+
export function createSitesAdapter(signal,codex) {
|
|
3
|
+
let nativeButton,nativeInner,nativePlus,nativeSiteIcon,nativeMore,nativeExternal,nativeGlobe,nativeActionsClass;
|
|
4
|
+
const owned="data-mcx-sites";
|
|
5
|
+
const text=(en,zh)=>(document.documentElement.lang || navigator.language).startsWith("zh") ? zh : en;
|
|
6
|
+
const visible=e=>!!e?.isConnected && e.getBoundingClientRect().width>0 && e.getBoundingClientRect().height>0;
|
|
7
|
+
async function until(check) {
|
|
8
|
+
const end=Date.now()+10000;
|
|
9
|
+
while(!signal.aborted && Date.now()<end) {const value=check();if(value)return value;await new Promise(resolve=>setTimeout(resolve,80));}
|
|
10
|
+
throw new Error(text("The native view did not become ready.","原生视图未就绪。"));
|
|
11
|
+
}
|
|
12
|
+
function nativeProps(node, predicate) {
|
|
13
|
+
let fiber = codex.currentFiber(node);
|
|
14
|
+
for (let depth = 0; fiber && depth < 220; depth++, fiber = fiber.return) { const p = fiber.memoizedProps; if (p && predicate(p)) return p; }
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
function router() {
|
|
18
|
+
const p = nativeProps(document.querySelector("[data-sidebar-destination]"), p => p.router?.navigate || p.value?.router?.navigate);
|
|
19
|
+
return p?.router || p?.value?.router;
|
|
20
|
+
}
|
|
21
|
+
function pageInfo() {
|
|
22
|
+
if (router()?.state?.location?.pathname !== "/space/sites") return null;
|
|
23
|
+
const input = [...document.querySelectorAll("input")].find(e => /Search sites|搜索站点|搜尋網站/.test(e.placeholder));
|
|
24
|
+
if (!input) return null;
|
|
25
|
+
let page = input.parentElement;
|
|
26
|
+
while (page && !page.classList.contains("min-h-full")) page = page.parentElement;
|
|
27
|
+
if (!page || page.children.length < 2) return null;
|
|
28
|
+
const content = page.children[1], list = page.querySelector('[data-testid="sites-table"], [data-testid="sites-grid"]');
|
|
29
|
+
const native = nativeProps(list || content.querySelector("button") || content.querySelector("p") || content.lastElementChild?.lastElementChild, p => "viewMode" in p && "shared" in p);
|
|
30
|
+
const grid = native ? native.viewMode === "grid" : !!page.querySelector('button[aria-label="Grid view"][aria-pressed="true"],button[aria-label="网格视图"][aria-pressed="true"]');
|
|
31
|
+
const shared = native ? !!native.shared : [...page.querySelectorAll("button[aria-pressed=true]")].some(b => /Shared with you|与你共享|與你共用/.test(b.textContent));
|
|
32
|
+
const create = [...page.firstElementChild.querySelectorAll("button")].find(b => /^(New|新建|新增)$/.test(b.textContent.trim()) && !b.hasAttribute(owned));
|
|
33
|
+
codex.report("sites-page",true);
|
|
34
|
+
return { page, input, content, list, grid, shared, create };
|
|
35
|
+
}
|
|
36
|
+
function fullViewControl(enter) {
|
|
37
|
+
const pattern = enter ? /^(Enter full view|进入完整视图|进入全屏视图|進入完整檢視|進入全螢幕檢視)$/ : /^(Exit full view|退出完整视图|退出全屏视图|退出完整檢視|退出全螢幕檢視)$/;
|
|
38
|
+
return [...document.querySelectorAll("button")].find(e => visible(e) && [e.getAttribute("aria-label"), e.getAttribute("title"), e.getAttribute("data-tooltip-content")].some(value => pattern.test(value || "")));
|
|
39
|
+
}
|
|
40
|
+
async function open(origin,url,cwd) {
|
|
41
|
+
const r = router(); if (!r) throw new Error(text("Native navigation is unavailable.", "原生导航暂不可用。"));
|
|
42
|
+
if (origin.kind === "thread") {
|
|
43
|
+
await r.navigate(`/local/${origin.threadId}`);
|
|
44
|
+
await until(() => codex.currentThreadId() === origin.threadId);
|
|
45
|
+
} else {
|
|
46
|
+
const nonce = Date.now();
|
|
47
|
+
// 项目网页必须进入本地执行草稿,普通聊天模式会丢弃项目上下文。
|
|
48
|
+
await r.navigate("/", { state: {
|
|
49
|
+
project: origin.kind === "project" ? { type: "local", projectId: origin.projectId } : null,
|
|
50
|
+
...(origin.kind === "project" ? { prefillLocalExecution: true, prefillComposerMode: "local" } : {}),
|
|
51
|
+
focusComposerNonce: nonce
|
|
52
|
+
} });
|
|
53
|
+
await until(() => r.state.location.pathname === "/" && [...document.querySelectorAll('[data-codex-composer="true"]')].some(e => visible(e) && nativeProps(e, p => p.value?.kind === "new" && p.value.focusComposerNonce === nonce)));
|
|
54
|
+
// 等原生草稿的 layout/effect 完成,浏览器再绑定到当前草稿。
|
|
55
|
+
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
56
|
+
}
|
|
57
|
+
// 只在同一原聊天中复用网址相同的原生 tab,项目新草稿仍走新建路径。
|
|
58
|
+
const existing = origin.kind === "thread" && [...document.querySelectorAll('[role="tab"]')].find(e => {
|
|
59
|
+
const p = nativeProps(e, p => p.tab?.kind === "browser");
|
|
60
|
+
return p?.tab.props?.browserConversationId === origin.threadId && p.tab.durableRoute?.params?.restoreUrl === url;
|
|
61
|
+
});
|
|
62
|
+
if (existing) existing.click();
|
|
63
|
+
else window.postMessage({ type: "open-browser-tab", ...(origin.kind === "thread" ? { conversationId: origin.threadId } : {}), initialUrl: url, ...(origin.kind === "project" ? { cwd: cwd } : {}), source: "manual", initiator: "sites_library" }, location.origin);
|
|
64
|
+
|
|
65
|
+
}
|
|
66
|
+
function templates(page) {
|
|
67
|
+
const create = page.create;
|
|
68
|
+
if (create) {
|
|
69
|
+
const template = page.page.querySelector('button[data-size="lg"][data-color="secondary"]');
|
|
70
|
+
nativeButton = template.className; nativeInner = template.firstElementChild.className;
|
|
71
|
+
nativePlus = create.querySelector("svg")?.cloneNode(true);
|
|
72
|
+
}
|
|
73
|
+
nativeSiteIcon ||= document.querySelector('[data-sidebar-destination="builtin:sites"] svg')?.cloneNode(true);
|
|
74
|
+
const row = page.list?.querySelector("[data-appgen-row], article");
|
|
75
|
+
nativeExternal ||= row?.querySelector('button[aria-label*="new tab"]')?.cloneNode(true);
|
|
76
|
+
nativeActionsClass ||= row?.lastElementChild?.className;
|
|
77
|
+
nativeGlobe ||= [...(page.list?.querySelectorAll("[data-appgen-row]") || [])].find(r => r.children[2]?.textContent.includes("Everyone"))?.children[2]?.querySelector("svg")?.cloneNode(true);
|
|
78
|
+
nativeMore ||= row?.querySelector('button[aria-haspopup="menu"] svg')?.cloneNode(true);
|
|
79
|
+
return {nativeButton,nativeInner,nativePlus,nativeSiteIcon,nativeMore,nativeExternal,nativeGlobe,nativeActionsClass};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {templates,pageInfo,fullViewControl,open,path:()=>router()?.state?.location?.pathname};
|
|
83
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const run = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
export function parseListeners(output) {
|
|
7
|
+
let pid, command;
|
|
8
|
+
const entries = new Map();
|
|
9
|
+
for (const line of output.split("\n")) {
|
|
10
|
+
if (line[0] === "p") pid = Number(line.slice(1));
|
|
11
|
+
if (line[0] === "c") command = line.slice(1);
|
|
12
|
+
if (line[0] !== "n" || !pid) continue;
|
|
13
|
+
const port = Number(line.match(/:(\d+)$/)?.[1]);
|
|
14
|
+
if (!port) continue;
|
|
15
|
+
const address = line.slice(1, line.lastIndexOf(":"));
|
|
16
|
+
// 只访问归属于项目的本机监听地址,不探测局域网。
|
|
17
|
+
if (!["*", "0.0.0.0", "127.0.0.1", "[::]", "[::1]"].includes(address)) continue;
|
|
18
|
+
const host = address === "[::1]" ? "[::1]" : "127.0.0.1";
|
|
19
|
+
entries.set(`${pid}:${port}`, { pid, command, port, url: `http://${host}:${port}/` });
|
|
20
|
+
}
|
|
21
|
+
return [...entries.values()];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function probePage(url, { fetchImpl = fetch, timeoutMs = 1400 } = {}) {
|
|
25
|
+
const response = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs), redirect: "manual", headers: { Accept: "text/html" } });
|
|
26
|
+
// 自动发现不跟随跳转,以免一次本机检查变成任意网络访问。
|
|
27
|
+
if (!response.ok || !/text\/html|application\/xhtml\+xml/i.test(response.headers.get("content-type") ?? "")) { await response.body?.cancel(); return null; }
|
|
28
|
+
const reader = response.body?.getReader();
|
|
29
|
+
if (!reader) return null;
|
|
30
|
+
let text = "", bytes = 0;
|
|
31
|
+
const decoder = new TextDecoder();
|
|
32
|
+
try {
|
|
33
|
+
while (bytes < 65536) {
|
|
34
|
+
const { done, value } = await reader.read();
|
|
35
|
+
if (done) break;
|
|
36
|
+
bytes += value.byteLength;
|
|
37
|
+
text += decoder.decode(value.slice(0, Math.max(0, 65536 - (bytes - value.byteLength))), { stream: true });
|
|
38
|
+
}
|
|
39
|
+
} finally { await reader.cancel(); }
|
|
40
|
+
if (!/<(?:!doctype\s+html|html|head|body|title|div|main)\b/i.test(text)) return null;
|
|
41
|
+
const title = text.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.replace(/\s+/g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")?.trim().slice(0, 160);
|
|
42
|
+
return { title: title || new URL(url).host };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function localListeners({ platform = process.platform, runImpl = run } = {}) {
|
|
46
|
+
if (platform === "win32") {
|
|
47
|
+
// Windows 不公开进程 cwd;仅采用命令行中可确认的项目绝对路径。
|
|
48
|
+
const script = '$p=@{}; Get-CimInstance Win32_Process | ForEach-Object {$p[[int]$_.ProcessId]=$_.CommandLine}; @(Get-NetTCPConnection -State Listen | ForEach-Object { [pscustomobject]@{pid=$_.OwningProcess;address=$_.LocalAddress;port=$_.LocalPort;command=$p[[int]$_.OwningProcess]} }) | ConvertTo-Json -Compress';
|
|
49
|
+
const { stdout } = await runImpl("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { timeout: 4000, maxBuffer: 2 * 1024 * 1024 });
|
|
50
|
+
return [].concat(JSON.parse(stdout || "[]")).filter(e => ["::", "::1", "0.0.0.0", "127.0.0.1"].includes(e.address)).map(e => ({ ...e, url: `http://${e.address === "::1" ? "[::1]" : "127.0.0.1"}:${e.port}/` }));
|
|
51
|
+
}
|
|
52
|
+
let stdout;
|
|
53
|
+
try { ({ stdout } = await runImpl("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-Fpcn"], { timeout: 4000, maxBuffer: 2 * 1024 * 1024 })); }
|
|
54
|
+
catch (error) { if (error.code === 1 && !error.stdout) return []; throw error; }
|
|
55
|
+
const entries = parseListeners(stdout);
|
|
56
|
+
if (!entries.length) return [];
|
|
57
|
+
const pids = [...new Set(entries.map(e => e.pid))].join(",");
|
|
58
|
+
const [result, processInfo] = await Promise.all([
|
|
59
|
+
runImpl("lsof", ["-a", "-p", pids, "-d", "cwd", "-Fn"], { timeout: 4000, maxBuffer: 2 * 1024 * 1024 }),
|
|
60
|
+
runImpl("ps", ["-ww", "-p", pids, "-o", "pid=,args="], { timeout: 2000, maxBuffer: 2 * 1024 * 1024 }),
|
|
61
|
+
].map(task => task.catch(error => {
|
|
62
|
+
// 服务可在两次枚举之间退出,保留仍存活进程的输出。
|
|
63
|
+
if (error.code === 1) return { stdout: error.stdout || "" };
|
|
64
|
+
throw error;
|
|
65
|
+
})));
|
|
66
|
+
const commands = new Map(processInfo.stdout.split("\n").flatMap(line => {
|
|
67
|
+
const match = line.trim().match(/^(\d+)\s+(.+)$/);
|
|
68
|
+
return match ? [[Number(match[1]), match[2]]] : [];
|
|
69
|
+
}));
|
|
70
|
+
let pid; const cwd = new Map();
|
|
71
|
+
for (const line of result.stdout.split("\n")) { if (line[0] === "p") pid = Number(line.slice(1)); if (line[0] === "n") cwd.set(pid, line.slice(1)); }
|
|
72
|
+
return entries.map(e => ({ ...e, command: commands.get(e.pid) || "", cwd: cwd.get(e.pid) }));
|
|
73
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFile, readdir, realpath } from "node:fs/promises";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const ignored = new Set(["node_modules", "dist", "build", "out", "vendor", "target", "coverage"]);
|
|
6
|
+
export const entryKey = (projectId, directory) => createHash("sha256").update(`${projectId}\0${directory}`).digest("hex");
|
|
7
|
+
export async function findProjectSites(projects) {
|
|
8
|
+
const found = [], visited = new Set();
|
|
9
|
+
let remaining = 4000;
|
|
10
|
+
async function walk(directory, project, depth) {
|
|
11
|
+
if (--remaining < 0 || depth > 4) return;
|
|
12
|
+
const canonical = await realpath(directory).catch(() => null);
|
|
13
|
+
if (!canonical || visited.has(canonical)) return; visited.add(canonical);
|
|
14
|
+
let entries; try { entries = await readdir(canonical, { withFileTypes: true }); } catch { return; }
|
|
15
|
+
let pkg;
|
|
16
|
+
if (entries.some(e => e.name === "package.json")) { try { pkg = JSON.parse(await readFile(path.join(canonical, "package.json"), "utf8")); } catch {} }
|
|
17
|
+
const scripts = Object.entries(pkg?.scripts || {});
|
|
18
|
+
const selected = ["dev", "start", "serve"].flatMap(name => scripts.filter(([key]) => key === name))
|
|
19
|
+
.map(([script, command]) => ({ script, command, framework: String(command).match(/^(vite|next\s+dev|astro\s+dev)(?:\s|$)/)?.[1]?.split(" ")[0] }))
|
|
20
|
+
.find(s => s.framework && !/[;&|`$<>\n]/.test(s.command));
|
|
21
|
+
// 不把只有 dev/watch 名称的编译器、MCP 或模拟器当作网站。
|
|
22
|
+
const staticEntry = !pkg && entries.some(e => e.isFile() && e.name === "index.html");
|
|
23
|
+
if (selected || staticEntry) {
|
|
24
|
+
let title = pkg?.name || path.basename(canonical);
|
|
25
|
+
if (staticEntry) { const html = await readFile(path.join(canonical, "index.html"), "utf8").catch(() => ""); title = html.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim() || title; }
|
|
26
|
+
found.push({ entryId: entryKey(project.id, canonical), directory: canonical, projectId: project.id, projectName: project.name, title, framework: selected?.framework || "static", script: selected?.script || null, command: selected?.command || null, url: null, status: "stopped" });
|
|
27
|
+
}
|
|
28
|
+
for (const entry of entries) if (entry.isDirectory() && !entry.name.startsWith(".") && !ignored.has(entry.name)) await walk(path.join(canonical, entry.name), project, depth + 1);
|
|
29
|
+
}
|
|
30
|
+
// 深层项目先归属,避免被父项目重复收录。
|
|
31
|
+
const roots = projects.flatMap(p => p.rootPaths.map(root => ({ root, project: p }))).sort((a,b) => b.root.length - a.root.length);
|
|
32
|
+
for (const { root, project } of roots) await walk(root, project, 0);
|
|
33
|
+
return found;
|
|
34
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
export class SiteLauncher {
|
|
8
|
+
constructor(probe) { this.probe = probe; this.running = new Map(); this.pending = new Map(); }
|
|
9
|
+
async start(entry) {
|
|
10
|
+
if (this.pending.has(entry.entryId)) return this.pending.get(entry.entryId);
|
|
11
|
+
const task = this.launch(entry).finally(() => this.pending.delete(entry.entryId)); this.pending.set(entry.entryId, task); return task;
|
|
12
|
+
}
|
|
13
|
+
async launch(entry) {
|
|
14
|
+
const existing = this.running.get(entry.entryId);
|
|
15
|
+
if (existing) { try { if (await this.probe(existing.url)) return existing.url; } catch {} existing.stop(); this.running.delete(entry.entryId); }
|
|
16
|
+
if (entry.framework === "static") {
|
|
17
|
+
const root = await realpath(entry.directory);
|
|
18
|
+
const types = { ".html":"text/html; charset=utf-8", ".js":"text/javascript", ".mjs":"text/javascript", ".css":"text/css", ".json":"application/json", ".svg":"image/svg+xml", ".png":"image/png", ".jpg":"image/jpeg", ".webp":"image/webp", ".woff2":"font/woff2" };
|
|
19
|
+
const server = createServer(async (req,res) => {
|
|
20
|
+
if (!["GET","HEAD"].includes(req.method)) { res.writeHead(405); res.end(); return; }
|
|
21
|
+
try {
|
|
22
|
+
const pathname = decodeURIComponent(new URL(req.url,"http://localhost").pathname);
|
|
23
|
+
if (pathname.split("/").some(p => p.startsWith("."))) throw Error();
|
|
24
|
+
const file = await realpath(path.join(root, pathname.endsWith("/") ? pathname + "index.html" : pathname));
|
|
25
|
+
const relative = path.relative(root,file); if (relative.startsWith("..") || path.isAbsolute(relative)) throw Error();
|
|
26
|
+
const bytes = await readFile(file); res.setHeader("Content-Type",types[path.extname(file)] || "application/octet-stream");res.end(req.method === "HEAD" ? undefined : bytes);
|
|
27
|
+
} catch { res.writeHead(404);res.end(); }
|
|
28
|
+
});
|
|
29
|
+
await new Promise((resolve,reject) => { server.once("error",reject);server.listen(0,"127.0.0.1",resolve); });
|
|
30
|
+
const url = `http://127.0.0.1:${server.address().port}/`; this.running.set(entry.entryId,{url,stop:()=>server.close()}); return url;
|
|
31
|
+
}
|
|
32
|
+
if (!["vite","next","astro"].includes(entry.framework)) throw Error("No supported start command was found for this site.");
|
|
33
|
+
// 只使用已安装的框架,不安装依赖、不运行任意 package 脚本。
|
|
34
|
+
const require = createRequire(path.join(entry.directory,"package.json"));
|
|
35
|
+
let packageFile; try { packageFile = require.resolve(`${entry.framework}/package.json`); } catch { throw Error("Project dependencies are not installed. Install them in the project first."); }
|
|
36
|
+
const pkg = JSON.parse(await readFile(packageFile,"utf8")); const relativeBin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.[entry.framework];
|
|
37
|
+
if (!relativeBin) throw Error("Framework executable is unavailable.");
|
|
38
|
+
const reserve = createServer(); await new Promise(r=>reserve.listen(0,"127.0.0.1",r));const port=reserve.address().port;await new Promise(r=>reserve.close(r));
|
|
39
|
+
const args = entry.framework === "next" ? ["dev","--hostname","127.0.0.1","--port",String(port)] : [...(entry.framework === "astro" ? ["dev"] : []),"--host","127.0.0.1","--port",String(port),...(entry.framework === "vite" ? ["--strictPort"] : [])];
|
|
40
|
+
// 有自定义参数的配方不能静默丢弃,交由项目维护者整理后再启动。
|
|
41
|
+
if (!/^(vite|next\s+dev|astro\s+dev)(?:\s+--(?:host|hostname|port)\s+[\w.:-]+|\s+--(?:turbopack|strictPort))*\s*$/.test(entry.command || "")) throw Error("This site has a custom start command. Start it in the project first.");
|
|
42
|
+
if (/--turbopack\b/.test(entry.command)) args.push("--turbopack");
|
|
43
|
+
const child=spawn(process.execPath,[path.resolve(path.dirname(packageFile),relativeBin),...args],{cwd:entry.directory,stdio:["ignore","pipe","pipe"],env:process.env});
|
|
44
|
+
let failure; child.on("error",e=>{failure=e;}); child.stdout.resume();child.stderr.resume();
|
|
45
|
+
const url=`http://127.0.0.1:${port}/`; const record={url,stop:()=>child.kill("SIGTERM")};this.running.set(entry.entryId,record);
|
|
46
|
+
child.once("exit",()=>{if(this.running.get(entry.entryId)===record)this.running.delete(entry.entryId);});
|
|
47
|
+
try {
|
|
48
|
+
const deadline=Date.now()+25000;
|
|
49
|
+
while(Date.now()<deadline){if(failure)throw failure;if(child.exitCode!==null)throw Error("Website process exited before becoming ready.");try{if(await this.probe(url,{timeoutMs:1000}))return url;}catch{}await new Promise(r=>setTimeout(r,400));}
|
|
50
|
+
throw Error("Website did not become ready within 25 seconds.");
|
|
51
|
+
} catch(e){record.stop();this.running.delete(entry.entryId);throw e;}
|
|
52
|
+
}
|
|
53
|
+
stop(){for(const entry of this.running.values())entry.stop();this.running.clear();}
|
|
54
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { access, chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
const run = promisify(execFile);
|
|
9
|
+
export async function captureSitePreview(url) {
|
|
10
|
+
const candidates = process.platform === "darwin" ? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"] : process.platform === "win32" ? [process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA].filter(Boolean).flatMap(root => [path.join(root, "Google/Chrome/Application/chrome.exe"), path.join(root, "Microsoft/Edge/Application/msedge.exe")]) : ["/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"];
|
|
11
|
+
let browser;
|
|
12
|
+
for (const candidate of candidates) { try { await access(candidate); browser = candidate; break; } catch {} }
|
|
13
|
+
if (!browser) throw new Error("A Chromium browser is required for site previews.");
|
|
14
|
+
const temporary = await mkdtemp(path.join(os.tmpdir(), "minecodex-site-preview-"));
|
|
15
|
+
const screenshot = path.join(temporary, "preview.png");
|
|
16
|
+
try {
|
|
17
|
+
// 独立无界面 profile,不使用用户浏览器的登录状态或已有窗口。
|
|
18
|
+
const { stdout } = await run(browser, ["--headless=new", "--dump-dom", "--no-first-run", "--no-default-browser-check", "--disable-background-networking", "--disable-extensions", "--hide-scrollbars", "--mute-audio", `--user-data-dir=${path.join(temporary, "profile")}`, "--window-size=960,600", "--timeout=8000", "--virtual-time-budget=1200", `--screenshot=${screenshot}`, url], { timeout: 12000, maxBuffer: 1024 * 1024 });
|
|
19
|
+
if (/chrome-error:\/\/chromewebdata|id="main-frame-error"/.test(stdout)) throw new Error("Site could not be loaded.");
|
|
20
|
+
const bytes = await readFile(screenshot);
|
|
21
|
+
if (bytes.length > 2 * 1024 * 1024 || bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a") throw new Error("Invalid site preview.");
|
|
22
|
+
return bytes;
|
|
23
|
+
} finally { await rm(temporary, { recursive: true, force: true, maxRetries: 3 }); }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class SitePreviews {
|
|
27
|
+
constructor(directory, capture = captureSitePreview) { this.directory = directory; this.capture = capture; this.pending = new Map(); this.results = new Map(); this.queue = Promise.resolve(); }
|
|
28
|
+
async cached(url) {
|
|
29
|
+
const file = path.join(this.directory, createHash("sha256").update(url).digest("hex") + ".png");
|
|
30
|
+
try { return { image: `data:image/png;base64,${(await readFile(file)).toString("base64")}` }; } catch(e) { if(e.code === "ENOENT")return {image:null};throw e; }
|
|
31
|
+
}
|
|
32
|
+
request(url) {
|
|
33
|
+
const previous = this.results.get(url);
|
|
34
|
+
if (previous && (previous.image || Date.now() - previous.finishedAt < 60000)) return previous;
|
|
35
|
+
if (!this.pending.has(url)) {
|
|
36
|
+
this.get(url).then(result => this.results.set(url, result), () => this.results.set(url, { image: null, finishedAt: Date.now() }));
|
|
37
|
+
}
|
|
38
|
+
return { pending: true };
|
|
39
|
+
}
|
|
40
|
+
async get(url) {
|
|
41
|
+
if (this.pending.has(url)) return this.pending.get(url);
|
|
42
|
+
const file = path.join(this.directory, createHash("sha256").update(url).digest("hex") + ".png");
|
|
43
|
+
const task = (async () => {
|
|
44
|
+
try { return await readFile(file); } catch (e) { if (e.code !== "ENOENT") throw e; }
|
|
45
|
+
const capture = this.queue.catch(() => {}).then(async () => {
|
|
46
|
+
const bytes = await this.capture(url); await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
47
|
+
await writeFile(file, bytes, { mode: 0o600 }); await chmod(file, 0o600); return bytes;
|
|
48
|
+
});
|
|
49
|
+
this.queue = capture; return capture;
|
|
50
|
+
})().then(bytes => ({ image: `data:image/png;base64,${bytes.toString("base64")}` })).finally(() => this.pending.delete(url));
|
|
51
|
+
this.pending.set(url, task); return task;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { normalizeSite, projectForDirectory, isLocalAddress } from "../domain/site.mjs";
|
|
2
|
+
import { localListeners, probePage } from "./discovery.mjs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { mkdir, readFile, rename, writeFile, realpath } from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { CodexCatalog } from "../adapters/codex/catalog.mjs";
|
|
8
|
+
|
|
9
|
+
import { findProjectSites, entryKey } from "./project-sites.mjs";
|
|
10
|
+
import { SiteLauncher } from "./site-launcher.mjs";
|
|
11
|
+
import { SitePreviews } from "./sites-preview.mjs";
|
|
12
|
+
|
|
13
|
+
const loops = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
14
|
+
export class SitesService {
|
|
15
|
+
constructor({ dataDir, codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), listeners = localListeners, probe = probePage, catalog = new CodexCatalog(codexHome) } = {}) {
|
|
16
|
+
const support = process.platform === "win32" ? path.join(process.env.APPDATA || os.homedir(), "MineCodex") : path.join(os.homedir(), "Library", "Application Support", "MineCodex");
|
|
17
|
+
this.file = path.join(dataDir || process.env.MINECODEX_SITES_DATA_DIR || path.join(support, "sites"), "sites.json");
|
|
18
|
+
this.previews = new SitePreviews(path.join(path.dirname(this.file), "previews")); this.discoveredUrls = new Set(); this.entries = new Map(); this.discoveryQueue = Promise.resolve();
|
|
19
|
+
this.launcher = new SiteLauncher(probe); this.launchJobs = new Map();
|
|
20
|
+
this.catalogReader = catalog; this.listeners = listeners; this.probe = probe; this.queue = Promise.resolve();
|
|
21
|
+
}
|
|
22
|
+
catalog() { return this.catalogReader.snapshot(); }
|
|
23
|
+
async read() {
|
|
24
|
+
try { const data = JSON.parse(await readFile(this.file, "utf8")); if (data.schemaVersion !== 1 || !Array.isArray(data.sites)) throw new Error("Unsupported sites data."); return data.sites.map(site => site.url ? { ...site, hosting: isLocalAddress(new URL(site.url).hostname) ? "local" : "self-hosted" } : site); }
|
|
25
|
+
catch (error) { if (error.code === "ENOENT") return []; throw error; }
|
|
26
|
+
}
|
|
27
|
+
async mutate(operation) {
|
|
28
|
+
const task = this.queue.catch(() => {}).then(async () => {
|
|
29
|
+
const sites = await this.read(); const result = operation(sites);
|
|
30
|
+
await mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 });
|
|
31
|
+
const temporary = `${this.file}.${randomUUID()}.tmp`;
|
|
32
|
+
await writeFile(temporary, JSON.stringify({ schemaVersion: 1, sites }, null, 2) + "\n", { mode: 0o600 });
|
|
33
|
+
await rename(temporary, this.file); return result;
|
|
34
|
+
});
|
|
35
|
+
this.queue = task; return task;
|
|
36
|
+
}
|
|
37
|
+
discover() {
|
|
38
|
+
const task = this.discoveryQueue.catch(() => {}).then(() => this.scan()); this.discoveryQueue = task; return task;
|
|
39
|
+
}
|
|
40
|
+
async scan() {
|
|
41
|
+
const { projects } = await this.catalog();
|
|
42
|
+
const canonical = await Promise.all(projects.map(async p => ({ ...p, rootPaths: await Promise.all(p.rootPaths.map(r => realpath(r).catch(() => r))) })));
|
|
43
|
+
const definitions = await findProjectSites(canonical);
|
|
44
|
+
const listeners = await this.listeners(); const deadline = Date.now() + 7500; const candidates = []; const seen = new Set();
|
|
45
|
+
const attributed = await Promise.all(listeners.map(async e => {
|
|
46
|
+
const cwd = e.cwd ? await realpath(e.cwd).catch(() => e.cwd) : null;
|
|
47
|
+
const project = projectForDirectory(canonical, cwd) || (process.platform === "win32" ? canonical.filter(p => p.rootPaths.some(r => String(e.command).toLowerCase().includes((r + path.sep).toLowerCase()))).sort((a,b) => b.rootPaths[0].length - a.rootPaths[0].length)[0] : null);
|
|
48
|
+
return { ...e, project };
|
|
49
|
+
}));
|
|
50
|
+
// 模拟器桥接首页也会执行 simctl;须在 HTTP 探测前排除,避免安装授权弹窗。
|
|
51
|
+
// 无法读取完整命令时不主动探测,仍允许用户手动添加网址。
|
|
52
|
+
const pending = attributed.filter(e => e.project && e.command && !/(?:^|[\s/\\])(?:serve-sim|swiftui-preview-browser)(?:[.\s/\\]|$)/i.test(e.command));
|
|
53
|
+
await Promise.all(Array.from({ length: Math.min(6, pending.length) }, async () => {
|
|
54
|
+
while (pending.length && Date.now() < deadline) {
|
|
55
|
+
const entry = pending.shift();
|
|
56
|
+
if ([...this.launcher.running.values()].some(r => r.url === entry.url)) continue;
|
|
57
|
+
if (seen.has(entry.url)) continue; seen.add(entry.url);
|
|
58
|
+
try { const page = await this.probe(entry.url, { timeoutMs: Math.min(1400, Math.max(1, deadline - Date.now())) }); if (page) candidates.push({ title: page.title, url: entry.url, projectId: entry.project.id, projectName: entry.project.name, directory: entry.cwd, status: "running" }); } catch { /* 未就绪或非 HTTP 服务不列为网页。 */ }
|
|
59
|
+
}
|
|
60
|
+
}));
|
|
61
|
+
const historyFile = path.join(path.dirname(this.file), "project-history.json");
|
|
62
|
+
let history = {};
|
|
63
|
+
try { history = JSON.parse(await readFile(historyFile, "utf8")); } catch (e) { if (e.code !== "ENOENT") throw e; }
|
|
64
|
+
for (const candidate of candidates) {
|
|
65
|
+
const definition = definitions.filter(d => d.projectId === candidate.projectId && candidate.directory && projectForDirectory([{ rootPaths: [d.directory] }], candidate.directory)).sort((a,b) => b.directory.length - a.directory.length)[0];
|
|
66
|
+
candidate.entryId = definition?.entryId || entryKey(candidate.projectId, candidate.directory || candidate.url);
|
|
67
|
+
if (definition) { Object.assign(candidate, { framework: definition.framework, script: definition.script, command: definition.command }); definitions.splice(definitions.indexOf(definition), 1); }
|
|
68
|
+
history[candidate.entryId] = { ...history[candidate.entryId], projectId:candidate.projectId,projectName:candidate.projectName,directory:candidate.directory, lastSeenAt: Date.now(), url: candidate.url, title: candidate.title };
|
|
69
|
+
}
|
|
70
|
+
for (const [id, previous] of Object.entries(history)) {
|
|
71
|
+
if (previous.directory && projectForDirectory(canonical.filter(p => p.id === previous.projectId), previous.directory) && !candidates.some(c => c.entryId === id) && !definitions.some(c => c.entryId === id)) definitions.push({ ...previous,entryId:id,status:"stopped" });
|
|
72
|
+
}
|
|
73
|
+
for (const definition of definitions) {
|
|
74
|
+
const running = this.launcher.running.get(definition.entryId);
|
|
75
|
+
candidates.push({ ...definition, ...history[definition.entryId], url: running?.url || history[definition.entryId]?.url || null, status: running ? "running" : "stopped" });
|
|
76
|
+
}
|
|
77
|
+
for (const candidate of candidates) Object.assign(candidate, { lastSeenAt: history[candidate.entryId]?.lastSeenAt, lastStartedAt: history[candidate.entryId]?.lastStartedAt });
|
|
78
|
+
await mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 });
|
|
79
|
+
await writeFile(historyFile, JSON.stringify(history), { mode: 0o600 });
|
|
80
|
+
this.entries = new Map(candidates.map(c => [c.entryId, c])); this.scannedAt = Date.now();
|
|
81
|
+
this.discoveredUrls = new Set(candidates.filter(c => c.status === "running").map(c => c.url));
|
|
82
|
+
return { candidates: candidates.sort((a,b) => a.projectName.localeCompare(b.projectName) || (a.url || a.directory).localeCompare(b.url || b.directory)), scope: "local-projects" };
|
|
83
|
+
}
|
|
84
|
+
async action(payload = {}) {
|
|
85
|
+
if (payload.operation === "start") {
|
|
86
|
+
const site = (await this.read()).find(s => s.id === payload.id);
|
|
87
|
+
if (!site || site.origin.kind !== "project") throw new Error("Project site is unavailable.");
|
|
88
|
+
const job = this.launchJobs.get(site.id);
|
|
89
|
+
if (job) { if (job.pending) return job; this.launchJobs.delete(site.id); if (job.error) throw new Error(job.error); return job; }
|
|
90
|
+
await this.discover(); const entry = site.entryId ? this.entries.get(site.entryId) : [...this.entries.values()].find(e => e.url === site.url && e.projectId === site.origin.projectId);
|
|
91
|
+
if (!entry) throw new Error("Project website entry no longer exists.");
|
|
92
|
+
if (entry.status === "running") return { url: entry.url };
|
|
93
|
+
this.launchJobs.set(site.id, { pending: true });
|
|
94
|
+
this.launcher.start(entry).then(async url => {
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
await this.mutate(sites => { const saved = sites.find(s => s.id === site.id); if (saved) Object.assign(saved, {entryId:entry.entryId,url,status:"running",lastStartedAt:now,lastSeenAt:now}); });
|
|
97
|
+
const historyFile = path.join(path.dirname(this.file), "project-history.json");
|
|
98
|
+
// 和扫描共用串行队列,避免运行记录被并发覆盖。
|
|
99
|
+
const update = this.discoveryQueue.catch(() => {}).then(async () => {
|
|
100
|
+
const history = JSON.parse(await readFile(historyFile,"utf8")); history[entry.entryId] = { ...history[entry.entryId],url,lastStartedAt:now,lastSeenAt:now,title:entry.title }; await writeFile(historyFile,JSON.stringify(history),{mode:0o600});
|
|
101
|
+
}); this.discoveryQueue = update; await update;
|
|
102
|
+
this.entries.set(entry.entryId,{...entry,url,status:"running",lastStartedAt:now,lastSeenAt:now});
|
|
103
|
+
this.launchJobs.set(site.id,{url});
|
|
104
|
+
}).catch(error => this.launchJobs.set(site.id,{error:error.message}));
|
|
105
|
+
return { pending:true };
|
|
106
|
+
}
|
|
107
|
+
if (payload.operation === "preview") {
|
|
108
|
+
const url = new URL(String(payload.url)).href;
|
|
109
|
+
if (!this.discoveredUrls.has(url) && !(payload.cachedOnly && [...this.entries.values()].some(e => e.url === url)) && !(await this.read()).some(s => s.url === url)) throw new Error("Site is not in the library or discovery results.");
|
|
110
|
+
// 手动收录也不能让截图重新触发模拟器工具链安装。
|
|
111
|
+
const parsed = new URL(url);
|
|
112
|
+
if (loops.has(parsed.hostname)) {
|
|
113
|
+
const listeners = await this.listeners();
|
|
114
|
+
if (listeners.some(e => new URL(e.url).port === parsed.port && /(?:serve-sim|swiftui-preview-browser)/i.test(e.command))) return { image: null };
|
|
115
|
+
}
|
|
116
|
+
return payload.cachedOnly ? this.previews.cached(url) : this.previews.request(url);
|
|
117
|
+
}
|
|
118
|
+
if (payload.operation === "list") {
|
|
119
|
+
if (!this.scannedAt || Date.now() - this.scannedAt > 10000) await this.discover();
|
|
120
|
+
return { sites: (await this.read()).map(site => {
|
|
121
|
+
const entry = site.entryId ? this.entries.get(site.entryId) : [...this.entries.values()].find(e => e.url && e.url === site.url && e.projectId === site.origin.projectId);
|
|
122
|
+
return entry ? { ...site,...entry } : site;
|
|
123
|
+
}), ...await this.catalog() };
|
|
124
|
+
}
|
|
125
|
+
if (payload.operation === "discover") return this.discover();
|
|
126
|
+
if (payload.operation === "save") {
|
|
127
|
+
let site;
|
|
128
|
+
if (payload.site?.entryId) {
|
|
129
|
+
const candidate = this.entries.get(payload.site.entryId);
|
|
130
|
+
if (!candidate) throw new Error("Project site is no longer available. Reopen Add site.");
|
|
131
|
+
site = { ...candidate, hosting: "local", origin: { kind: "project", projectId: candidate.projectId } };
|
|
132
|
+
} else site = normalizeSite(payload.site);
|
|
133
|
+
const catalog = await this.catalog();
|
|
134
|
+
if (site.origin.kind === "project" && !catalog.projects.some(p => p.id === site.origin.projectId)) throw new Error("Project is no longer available. Choose another source.");
|
|
135
|
+
if (site.origin.kind === "thread" && !catalog.threads.some(t => t.id === site.origin.threadId)) throw new Error("Projectless chat is no longer available. Choose another source.");
|
|
136
|
+
return this.mutate(sites => {
|
|
137
|
+
const index = payload.id ? sites.findIndex(s => s.id === payload.id) : -1;
|
|
138
|
+
if (payload.id && index < 0) throw new Error("Site no longer exists. Refresh the list.");
|
|
139
|
+
const duplicate = sites.find(s => s.id !== payload.id && (site.entryId ? s.entryId === site.entryId || (!!site.url && s.url === site.url) : s.url === site.url) && JSON.stringify(s.origin) === JSON.stringify(site.origin));
|
|
140
|
+
if (duplicate) {
|
|
141
|
+
if (payload.id) throw new Error("This URL and development source are already saved in another entry.");
|
|
142
|
+
return duplicate;
|
|
143
|
+
}
|
|
144
|
+
const record = { ...site, id: payload.id || randomUUID(), createdAt: index < 0 ? Date.now() : sites[index].createdAt, updatedAt: Date.now() };
|
|
145
|
+
if (index < 0) sites.unshift(record); else sites[index] = record;
|
|
146
|
+
return record;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
if (payload.operation === "remove") return this.mutate(sites => { const i = sites.findIndex(s => s.id === payload.id); if (i >= 0) sites.splice(i, 1); return { removed: i >= 0 }; });
|
|
150
|
+
if (payload.operation === "resolve") {
|
|
151
|
+
let site = (await this.read()).find(s => s.id === payload.id);
|
|
152
|
+
if (!site) throw new Error("Site no longer exists.");
|
|
153
|
+
const catalog = await this.catalog();
|
|
154
|
+
if (site.origin.kind === "project" && !catalog.projects.some(p => p.id === site.origin.projectId)) return { site, missing: "project" };
|
|
155
|
+
if (site.origin.kind === "thread" && !catalog.threads.some(t => t.id === site.origin.threadId)) return { site, missing: "thread" };
|
|
156
|
+
if (site.entryId) { await this.discover(); const entry = site.entryId ? this.entries.get(site.entryId) : [...this.entries.values()].find(e => e.url === site.url && e.projectId === site.origin.projectId); if (entry) site = { ...site, ...entry }; }
|
|
157
|
+
const project = site.origin.kind === "project" ? catalog.projects.find(p => p.id === site.origin.projectId) : null;
|
|
158
|
+
return { site, ...(project ? { cwd: site.directory || project.rootPaths[0] } : {}) };
|
|
159
|
+
}
|
|
160
|
+
throw new Error("Unknown Sites operation.");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
export function isLocalAddress(hostname) {
|
|
4
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
5
|
+
if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host === "::1") return true;
|
|
6
|
+
const parts = host.split(".");
|
|
7
|
+
if (parts.length === 4 && parts.every(p => /^\d+$/.test(p) && Number(p) <= 255)) {
|
|
8
|
+
const [a, b] = parts.map(Number);
|
|
9
|
+
return a === 127 || a === 10 || a === 192 && b === 168 || a === 172 && b >= 16 && b <= 31 || a === 169 && b === 254;
|
|
10
|
+
}
|
|
11
|
+
// URL 已规范化 IPv6;识别私有、链路本地及 IPv4 映射地址。
|
|
12
|
+
if (/^(fc|fd)[0-9a-f]{2}:|^fe[89ab][0-9a-f]:/.test(host)) return true;
|
|
13
|
+
const mapped = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
14
|
+
if (mapped) {
|
|
15
|
+
const high = parseInt(mapped[1], 16), low = parseInt(mapped[2], 16);
|
|
16
|
+
return isLocalAddress([high >> 8, high & 255, low >> 8, low & 255].join("."));
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
export function normalizeSite(input) {
|
|
21
|
+
const url = new URL(String(input.url ?? "").trim());
|
|
22
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw new Error("Use an HTTP or HTTPS URL without embedded credentials.");
|
|
23
|
+
const title = String(input.title ?? "").trim().slice(0, 160) || url.hostname;
|
|
24
|
+
const origin = input.origin;
|
|
25
|
+
if (!origin || !["project", "thread", "external"].includes(origin.kind)) throw new Error("Choose the site's development source.");
|
|
26
|
+
const cleanOrigin = { kind: origin.kind };
|
|
27
|
+
if (origin.kind !== "external") {
|
|
28
|
+
const field = origin.kind === "project" ? "projectId" : "threadId";
|
|
29
|
+
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(origin[field] ?? "")) throw new Error("Choose a valid development source.");
|
|
30
|
+
cleanOrigin[field] = origin[field];
|
|
31
|
+
}
|
|
32
|
+
return { title, url: url.href, hosting: isLocalAddress(url.hostname) ? "local" : "self-hosted", origin: cleanOrigin };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function projectForDirectory(projects, cwd, pathApi = path) {
|
|
36
|
+
if (!cwd) return null;
|
|
37
|
+
return projects.flatMap(project => project.rootPaths.map(root => ({ project, root })))
|
|
38
|
+
.filter(({ root }) => { const relative = pathApi.relative(root, cwd); return relative === "" || (!relative.startsWith(`..${pathApi.sep}`) && relative !== ".." && !pathApi.isAbsolute(relative)); })
|
|
39
|
+
.sort((a, b) => b.root.length - a.root.length)[0]?.project ?? null;
|
|
40
|
+
}
|