diff --git a/README.md b/README.md index 6361d109..9a766660 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | MiniApp | What it does | Author | | --- | --- | --- | +| [OpenFilm](plugins/cristinamadureira353-svg/openfilm/) | Run OpenFilm inside MiniMax Code with the desktop interface: Studio editor, chat column and local agents, with a web-Studio fallback | [最爱双皮奶](https://github.com/cristinamadureira353-svg) | | [Dynamic Workflow Console](plugins/stevenjj33/workflow-console/) | Run and supervise MiniMax Code dynamic workflows from one page: live status, a stage-and-dependency flow graph, per-node output, approval and control, templates, and report export | [stevenjj33](https://github.com/stevenjj33) | | [MiniMax Photo](plugins/webguhui/minimax-photo/) | Layered image editing with an in-app AI assistant, local recovery and programmatic editing tools | [WebGuHui](https://github.com/WebGuHui) | | [MiniMax Video](plugins/webguhui/minimax-video/) | Local video editing with captions, a host Agent assistant, reviewable timeline edits, and MP4/MP3 export | [WebGuHui](https://github.com/WebGuHui) | diff --git a/README.zh-CN.md b/README.zh-CN.md index 58b60d69..0c0e2134 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -57,6 +57,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | MiniApp | 功能 | 作者 | | --- | --- | --- | +| [OpenFilm](plugins/cristinamadureira353-svg/openfilm/) | 把 OpenFilm 装进 MiniMax Code:桌面版界面(Studio 编辑器 + 聊天栏 + 本地 agent),桌面模式不可用时回落网页版 | [最爱双皮奶](https://github.com/cristinamadureira353-svg) | | [动态工作流控制台](plugins/stevenjj33/workflow-console/README.zh-CN.md) | 在一个页面里运行并盯住 MiniMax Code 动态工作流:实时状态、按阶段与依赖画出的流程图、节点输出、审核与控制、模板与报告导出 | [stevenjj33](https://github.com/stevenjj33) | | [MiniMax Photo](plugins/webguhui/minimax-photo/) | 图层图片编辑、应用内 AI 助手、本地恢复与程序化编辑工具 | [WebGuHui](https://github.com/WebGuHui) | | [MiniMax Video](plugins/webguhui/minimax-video/) | 本地视频剪辑、字幕、宿主 Agent 助手、可预览的时间线修改与 MP4/MP3 导出 | [WebGuHui](https://github.com/WebGuHui) | diff --git a/plugins/cristinamadureira353-svg/openfilm/.gitignore b/plugins/cristinamadureira353-svg/openfilm/.gitignore new file mode 100644 index 00000000..692780a8 --- /dev/null +++ b/plugins/cristinamadureira353-svg/openfilm/.gitignore @@ -0,0 +1,11 @@ +# 自己机器上的覆盖配置(个人路径、个人偏好),不进仓库 +miniapp.config.local.json + +# 依赖与系统垃圾 +node_modules/ +.DS_Store +*.log + +# 运行时的东西(token、锁、缓存)不该进包 +panel-token +starting.lock diff --git a/plugins/cristinamadureira353-svg/openfilm/.minimax-plugin/plugin.json b/plugins/cristinamadureira353-svg/openfilm/.minimax-plugin/plugin.json new file mode 100644 index 00000000..9dd76bf4 --- /dev/null +++ b/plugins/cristinamadureira353-svg/openfilm/.minimax-plugin/plugin.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "name": "openfilm", + "displayName": "OpenFilm", + "version": "1.0.0", + "description": "OpenFilm 本体装进 MiniMax Code:整个面板就是 OpenFilm 的完整界面(Studio 的编辑器 + 右边的聊天栏 + 本地 agent),没有额外的壳。跑的是 GitHub 官方仓库当前最新的发布版(npx openfilm@0.2.5),连正式版项目库 ~/Movies/OpenFilm/Projects。", + "author": "cristinamadureira353-svg", + "icon": "icon.png", + "category": "Productivity", + "exampleQueries": [ + "打开 OpenFilm 面板", + "在 OpenFilm 里看未命名那个项目", + "切到未命名 3 那个项目" + ], + "apps": [], + "mcpServers": [], + "skills": [ + "skills/openfilm/SKILL.md" + ], + "hooks": [] +} diff --git a/plugins/cristinamadureira353-svg/openfilm/LICENSE b/plugins/cristinamadureira353-svg/openfilm/LICENSE new file mode 100644 index 00000000..8e39faa5 --- /dev/null +++ b/plugins/cristinamadureira353-svg/openfilm/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 cristinamadureira353-svg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/cristinamadureira353-svg/openfilm/NOTICE.md b/plugins/cristinamadureira353-svg/openfilm/NOTICE.md new file mode 100644 index 00000000..b1b1b190 --- /dev/null +++ b/plugins/cristinamadureira353-svg/openfilm/NOTICE.md @@ -0,0 +1,20 @@ +# 原作者与出处 / Original project and credit + +**OpenFilm 是原作者的开源项目,原仓库:[openfilm/openfilm](https://github.com/openfilm/openfilm)。** + +OpenFilm 是原作者开源的作品,其源代码、界面与许可证均见原仓库: +https://github.com/openfilm/openfilm (许可证见该仓库的 `LICENSE` 等文件)。 + +**本仓库(openfilm-miniapp)只是将 OpenFilm 打包为 MiniMax Code 中的 MiniApp,方便在 MiniMax Code 里直接使用。** + +**本仓库的打包与公开分享已获得 OpenFilm 原作者本人授权(2026-10-24,由本仓库所有者确认)。** + +- 本仓库不是 OpenFilm 官方仓库,也不隶属于原作者团队;上述打包与分享已获原作者本人授权。 +- 本仓库不包含 OpenFilm 的源代码;运行时通过 `npx openfilm` 与用户本地的 OpenFilm 克隆来驱动它。 +- 打包过程中没有修改 OpenFilm 本身的行为;OpenFilm 的商标与名称归其权利人所有。 + +--- + +**OpenFilm is an open-source project by its authors: [openfilm/openfilm](https://github.com/openfilm/openfilm).** + +This repository only packages OpenFilm as a MiniApp for MiniMax Code. It is not the OpenFilm project and is not affiliated with its authors. **The original author has authorized this packaging and public sharing (confirmed by the repository owner on 2026-10-24).** No OpenFilm source code is vendored here; OpenFilm is driven at runtime via `npx openfilm` and the user's local clone. OpenFilm's own license and notices apply to OpenFilm itself. diff --git a/plugins/cristinamadureira353-svg/openfilm/README.md b/plugins/cristinamadureira353-svg/openfilm/README.md new file mode 100644 index 00000000..b4ecac9f --- /dev/null +++ b/plugins/cristinamadureira353-svg/openfilm/README.md @@ -0,0 +1,98 @@ +# OpenFilm MiniApp + +English | [简体中文](README.zh-CN.md) + +> **原作者 / Original project: [openfilm/openfilm](https://github.com/openfilm/openfilm).** OpenFilm is an open-source project by its authors. +> **This repository only packages OpenFilm as a MiniApp for MiniMax Code** — it is not the OpenFilm project, is not affiliated with its authors, and contains no OpenFilm source code. **The original author has authorized this packaging and public sharing.** See [NOTICE.md](NOTICE.md). + +Run **OpenFilm inside MiniMax Code** — the desktop app's interface (the Studio editor **with the chat column and local agents**), not just the web Studio. + +The panel is the OpenFilm desktop page itself. The native bridge the desktop app normally gets from Electron (`window.openfilmNative`) is replaced with an HTTP one, and the desktop app's own main-process handlers (chat, local agents, skills, agent roster) are reused as plain Node modules. So the chat, the agent menu and Settings → Agents behave like the desktop app, because they *are* the desktop app's code. + +## What you get + +- The full OpenFilm editor: media pool, viewer, inspector, timeline, export. +- The desktop chat column: conversation history, composer, agent menu, local agent turns. +- Settings → Agents with the real roster (Codex, Claude Code, MiniMax Code, BYOK, …). +- Opens your most recent OpenFilm project on launch; switching projects happens inside OpenFilm itself (no extra chrome of our own). +- An agent-callable endpoint (`POST /miniapp/open-desktop`) that launches/activates the installed OpenFilm desktop app. +- A pure web-Studio fallback when the desktop mode is unavailable. + +## Requirements + +- MiniMax Code **3.1.2 or newer** on macOS (the Host's loopback credential guards the panel's port). +- Node.js 22+ and `npx` (the panel starts OpenFilm with `npx -y openfilm@0.2.5`). +- **Desktop mode only:** a local clone of [openfilm/openfilm](https://github.com/openfilm/openfilm) and one shell build. `@openfilm/desktop` is not published to npm, so the desktop handlers can only come from the repository. + +```sh +git clone https://github.com/openfilm/openfilm.git ~/Developer/openfilm +cd ~/Developer/openfilm && pnpm install +pnpm --filter @openfilm/desktop build:shell +``` + +Without the repository the panel still works — it falls back to the plain web Studio (no chat column). + +## Install + +Copy this whole plugin directory (including the hidden `.minimax-plugin/` directory) into your MiniMax Code plugins directory, e.g. `~/.minimax/plugins/openfilm/`, restart MiniMax Code, then ask the Agent to "打开 OpenFilm 面板". + +The manifest must end up at `~/.minimax/plugins/openfilm/.minimax-plugin/plugin.json`. + +## Configuration + +Edit `miniapp.config.json` in the plugin directory. Put machine-specific overrides in `miniapp.config.local.json` (gitignored, never committed). + +| Key | Default | Meaning | +|---|---|---| +| `openfilmBin` / `openfilmArgs` | `npx` / `["-y","openfilm@0.2.5"]` | Which OpenFilm to start. | +| `flavor` | `release` | Which project list to show: `release` (`~/.openfilm`, `~/Movies/OpenFilm/Projects`) or `dev` (`~/.openfilm-dev`). | +| `desktopMode` | `true` | Desktop interface + chat + local agents. `false` = plain web Studio. | +| `openfilmRepo` | `~/Developer/openfilm` | Where the OpenFilm clone is (also `OPENFILM_REPO` env). Needed for desktop mode. | +| `shellDist` | `/apps/desktop/shell/dist` | The built desktop page. | +| `desktopApp` | auto-detected | The app the `POST /miniapp/open-desktop` endpoint launches. `[{ "path": "…", "args": ["…"] }]`; auto-detects `/Applications/OpenFilm.app` and dev runtimes in the clone. | +| `studioHome` / `studioPort` | `context.dataDir/openfilm-studio` / `4823` | The panel's own Studio home/port. Change them for isolated tests. | + +## Data & access + +- Starts `openfilm open` (via `npx`) on `127.0.0.1`, with its own `OPENFILM_HOME` (`openfilm-studio/` under the Host-assigned `context.dataDir`). Your desktop app's own Studio home is never taken over. +- State (Studio home, preferences, caches, logs, BYOK master key) is stored in that `context.dataDir` directory only. +- Reads (never writes) `studio.json` and each project's `.film/id` under the selected OpenFilm home to list projects. +- Reads/writes project data through OpenFilm's own API and desktop handlers (chat records under `.film/`, agent sessions in project folders), exactly as the desktop app does. +- Network: `npx` downloads OpenFilm on first run; agents you invoke use their own accounts/network as usual. + +## Security + +- **Loopback authentication is done by the Host.** Since MiniMax Code 3.1.2 the Host installs a per-run credential check on every `node:http` server in the MiniApp process: requests without the credential — including direct local connections and WebSocket upgrades — get `401` before reaching this package. This package therefore implements no token logic of its own. +- **No secrets in pages, client JavaScript, logs, or error responses.** The Studio key stays in the Node process and is only attached when proxying `/api/*` to Studio. Nothing is injected into the served HTML. +- **State lives in `context.dataDir`** (`openfilm-studio/` under it), owned by the Host. Nothing durable is written into the plugin directory. +- BYOK keys saved through the panel are sealed with AES-256-GCM using a local `0600` master key in that state directory (the desktop app uses the OS keychain instead; the two do not share key storage). + +The panel can drive local agents in project folders — install it only if you trust it, and review what you let an agent do. + +## Limitations + +- The Electron window itself cannot be embedded in a MiniApp; this runs the desktop *interface* with an HTTP native bridge. +- Native file/folder pickers and screen capture return `null` (no native dialogs in a browser). +- Desktop mode needs the OpenFilm clone + shell build (see Requirements). +- Tested on macOS only. Windows/Linux are untested (some code paths use `open`, `pbcopy`, `ps`). + +## Tested environment + +- MiniMax Code desktop 3.1.2+ on macOS (Apple Silicon), Node 22, OpenFilm 0.2.5. +- Desktop page renders with chat column and real conversation history; project switching works. +- `chat:load` returns real sessions; agent roster lists 11 agents; Codex ACP probe starts a session; Claude Code correctly reports not-installed. +- Acceptance script: 37 checks covering boot, tree, desktop IPC, roster, SSE, web fallback, missing-shell error, concurrent boot (one Studio only), dispose with open SSE streams, and the preview path (film page proxied, editor origin rewritten, bridge allowed same-origin, media served). + +Run the acceptance yourself: + +```sh +node test/accept-miniapp.mjs # SKIP_PROBE=1 to skip the Codex ACP probe +``` + +It uses temporary homes and ports 4900–4903 and cleans up after itself; it never touches your running panel or writes your projects. Desktop-mode checks need the OpenFilm clone + shell build (see Requirements). After installing, also open the panel once and check Settings → Agents. + +## License + +MIT — see [LICENSE](LICENSE). + +OpenFilm is by the authors of [openfilm/openfilm](https://github.com/openfilm/openfilm); this package only drives it at runtime and contains no OpenFilm source code. See [NOTICE.md](NOTICE.md) for the full attribution. diff --git a/plugins/cristinamadureira353-svg/openfilm/README.zh-CN.md b/plugins/cristinamadureira353-svg/openfilm/README.zh-CN.md new file mode 100644 index 00000000..3b68fcad --- /dev/null +++ b/plugins/cristinamadureira353-svg/openfilm/README.zh-CN.md @@ -0,0 +1,98 @@ +# OpenFilm MiniApp + +[English](README.md) | 简体中文 + +> **原作者:[openfilm/openfilm](https://github.com/openfilm/openfilm)。** OpenFilm 是原作者的开源作品。 +> **本仓库只是把 OpenFilm 打包成 MiniMax Code 里的 MiniApp,方便使用** —— 不是 OpenFilm 官方仓库,不隶属于原作者团队,也不包含 OpenFilm 源代码。**本仓库的打包与公开分享已获得原作者本人授权(2026-10-24,由本仓库所有者确认)。**完整署名见 [NOTICE.md](NOTICE.md)。 + +把 **OpenFilm 装进 MiniMax Code** —— 跑的是**桌面版的界面**(Studio 编辑器 + 右边那栏聊天 + 本地 agent),不是只有网页版 Studio。 + +面板里那一页就是 OpenFilm 桌面 app 自己的页面。桌面 app 本来从 Electron 拿到的原生桥(`window.openfilmNative`)被换成走 HTTP 的一份;桌面 app 主进程里的那些 handler(聊天、本地 agent、技能、agent 名单)作为纯 Node 模块原样复用。所以聊天、agent 菜单、设置 → 智能体的行为跟桌面版一致 —— 因为它们就是桌面版的代码。 + +## 有什么 + +- 完整的 OpenFilm 编辑器:素材库、预览、检视器、时间线、导出。 +- 桌面版那栏聊天:会话历史、输入框、agent 菜单、本地 agent 跑的回合。 +- 设置 → 智能体是真正的名单(Codex、Claude Code、MiniMax Code、自有密钥……)。 +- 打开时进你最近的 OpenFilm 项目;之后换项目在 OpenFilm 里面做(面板不加自己的顶栏)。 +- 一个 Agent 可调用的接口(`POST /miniapp/open-desktop`):叫起/激活你装着的 OpenFilm 桌面 app。 +- 桌面模式用不了时,自动回落到纯网页版 Studio。 + +## 前提 + +- macOS 上的 MiniMax Code **3.1.2 或更新**(Host 的 loopback 凭据守着面板的端口)。 +- Node.js 22+ 和 `npx`(面板用 `npx -y openfilm@0.2.5` 起 OpenFilm)。 +- **只有桌面模式需要**:本地克隆 [openfilm/openfilm](https://github.com/openfilm/openfilm),并构建一次桌面版页面。`@openfilm/desktop` 没发布到 npm,所以这些模块只能从仓库来。 + +```sh +git clone https://github.com/openfilm/openfilm.git ~/Developer/openfilm +cd ~/Developer/openfilm && pnpm install +pnpm --filter @openfilm/desktop build:shell +``` + +没有仓库也能用 —— 面板会回落到纯网页版 Studio(没有聊天栏)。 + +## 安装 + +把整个插件目录(包括隐藏的 `.minimax-plugin/`)复制到 MiniMax Code 的插件目录,例如 `~/.minimax/plugins/openfilm/`,重启 MiniMax Code,然后对 Agent 说「打开 OpenFilm 面板」。 + +manifest 最后要在:`~/.minimax/plugins/openfilm/.minimax-plugin/plugin.json`。 + +## 配置 + +改插件目录里的 `miniapp.config.json`。自己机器上的覆盖写 `miniapp.config.local.json`(已被 gitignore,不要提交)。 + +| 键 | 默认 | 含义 | +|---|---|---| +| `openfilmBin` / `openfilmArgs` | `npx` / `["-y","openfilm@0.2.5"]` | 起哪个 OpenFilm。 | +| `flavor` | `release` | 列哪套项目:`release`(`~/.openfilm`、`~/Movies/OpenFilm/Projects`)或 `dev`(`~/.openfilm-dev`)。 | +| `desktopMode` | `true` | 桌面版界面 + 聊天 + 本地 agent。`false` = 纯网页版。 | +| `openfilmRepo` | `~/Developer/openfilm` | OpenFilm 仓库在哪(也可用环境变量 `OPENFILM_REPO`)。桌面模式必须要。 | +| `shellDist` | `<仓库>/apps/desktop/shell/dist` | 构建出来的桌面版页面。 | +| `desktopApp` | 自动找 | `POST /miniapp/open-desktop` 这个接口开哪个 app。`[{ "path": "…", "args": ["…"] }]`;自动找 `/Applications/OpenFilm.app` 和仓库里的 Dev 版。 | +| `studioHome` / `studioPort` | `context.dataDir/openfilm-studio` / `4823` | 面板自己那份 Studio 的 home 和端口,测试时改成临时值。 | + +## 数据与访问 + +- 在 `127.0.0.1` 上用 `npx` 起 `openfilm open`,`OPENFILM_HOME` 是面板自己的(Host 给的 `context.dataDir` 下的 `openfilm-studio/`),不抢你桌面 app 的 Studio home。 +- 状态(Studio home、偏好、缓存、日志、BYOK 主密钥)只放在那个 `context.dataDir` 目录里。 +- 只读选中那套 OpenFilm home 里的 `studio.json` 和各项目的 `.film/id`,用来列项目。 +- 项目数据通过 OpenFilm 自己的 API 和桌面 handler 读写(`.film/` 下的聊天记录、项目目录里的 agent 会话),跟桌面 app 一样。 +- 网络:第一次跑 `npx` 会下载 OpenFilm;你调用的 agent 用它们自己的账号和网络。 + +## 安全 + +- **loopback 鉴权由 Host 做**。MiniMax Code 3.1.2 起,Host 在 MiniApp 进程里的每个 `node:http` 服务上都装了每次运行一个凭据的检查:没凭据的请求 —— 包括本机直连和 WebSocket —— 在到这个包之前就 401。所以这个包自己不造 token。 +- **页面、客户端 JS、日志、错误响应里都不放密钥**。Studio 的 key 只留在 Node 进程里,只在转发 `/api/*` 时带上;伺服出去的 HTML 里不注入任何密钥。 +- **状态放在 `context.dataDir`**(它下面的 `openfilm-studio/`),由 Host 所有;插件目录里不写任何运行时数据。 +- 在面板里保存的自有密钥(BYOK)用本机 `0600` 主密钥做 AES-256-GCM 封存(桌面 app 用的是系统钥匙串;两边的密钥存储不共享)。 + +这个面板能在项目目录里驱动本地 agent —— 只在你信任它的情况下安装,Agent 要做什么也由你把关。 + +## 限制 + +- MiniApp 嵌不了 Electron 窗口本身;这里跑的是桌面版的**界面** + HTTP 原生桥。 +- 原生文件/目录选择框和截屏返回 `null`(浏览器里没有原生对话框)。 +- 桌面模式需要 OpenFilm 仓库 + 构建(见前提)。 +- 只在 macOS 测过;Windows/Linux 未测(有些地方用了 `open`、`pbcopy`、`ps`)。 + +## 测试环境 + +- macOS(Apple Silicon)上的 MiniMax Code 桌面版 3.1.2+,Node 22,OpenFilm 0.2.5。 +- 桌面版页面带聊天栏和真实会话历史渲染正常,切项目正常。 +- `chat:load` 返回真实会话;agent 名单 11 个;Codex ACP 探测能起会话;Claude Code 如实报未安装。 +- 验收脚本 37 项:启动、项目列表、桌面 IPC、名单、SSE、网页降级、缺构建报错、并发只起一个 Studio、SSE 开着时 dispose 不挂,以及预览链路(影片页代理、EDITOR 改写、同源放开、素材可读)。 + +自己跑一遍验收: + +```sh +node test/accept-miniapp.mjs # 不想跑 Codex ACP 探测就 SKIP_PROBE=1 +``` + +全部实例用临时 home 和端口(4900–4903),跑完自己收掉;不碰你正在用的面板,也不写你的项目。桌面模式的检查需要 OpenFilm 仓库 + 构建(见前提)。装好后也开一次面板,看看设置 → 智能体。 + +## 许可 + +MIT,见 [LICENSE](LICENSE)。 + +OpenFilm 是 [openfilm/openfilm](https://github.com/openfilm/openfilm) 的作品;这个包只在运行时驱动它,不包含 OpenFilm 的源代码。完整署名见 [NOTICE.md](NOTICE.md)。 diff --git a/plugins/cristinamadureira353-svg/openfilm/icon.png b/plugins/cristinamadureira353-svg/openfilm/icon.png new file mode 100644 index 00000000..299a26cb Binary files /dev/null and b/plugins/cristinamadureira353-svg/openfilm/icon.png differ diff --git a/plugins/cristinamadureira353-svg/openfilm/miniapp.config.json b/plugins/cristinamadureira353-svg/openfilm/miniapp.config.json new file mode 100644 index 00000000..b2ed1cc7 --- /dev/null +++ b/plugins/cristinamadureira353-svg/openfilm/miniapp.config.json @@ -0,0 +1,10 @@ +{ + "openfilmBin": "npx", + "openfilmArgs": [ + "-y", + "openfilm@0.2.5" + ], + "flavor": "release", + "desktopMode": true, + "_说明": "openfilmBin/openfilmArgs:跑哪个 OpenFilm(默认 npm 上 GitHub 官方仓库的发布版 openfilm@0.2.5)。flavor:连哪一套项目列表 —— release=~/.openfilm + ~/Movies/OpenFilm/Projects(默认),dev=~/.openfilm-dev + ~/Movies/OpenFilm Dev/Projects。desktopMode:true = 面板里跑桌面版的界面(编辑器 + 右边那栏聊天 + 本地 agent),false = 纯网页版 Studio。openfilmRepo:桌面 app 的模块只在仓库里(@openfilm/desktop 没发 npm),桌面模式需要它,默认 ~/Developer/openfilm,也可用环境变量 OPENFILM_REPO 覆盖;仓库里要先 pnpm --filter @openfilm/desktop build:shell。desktopApp:「桌面版」按钮要叫起来的 app,可省略(自动找 /Applications/OpenFilm.app 与仓库里的 Dev 版);要指定就写 [{\"path\":\"…\",\"args\":[\"…\"]}]。studioHome/studioPort:测试用,指到临时目录与别的端口。自己机器上的覆盖写 miniapp.config.local.json(不提交)。" +} diff --git a/plugins/cristinamadureira353-svg/openfilm/miniapp/client/bridge-web.js b/plugins/cristinamadureira353-svg/openfilm/miniapp/client/bridge-web.js new file mode 100644 index 00000000..2a116627 --- /dev/null +++ b/plugins/cristinamadureira353-svg/openfilm/miniapp/client/bridge-web.js @@ -0,0 +1,198 @@ +/** + * 网页版的 openfilmNative:把桌面版 preload 的那套接口,搬到 HTTP 上。 + * + * 桌面 app 的 preload(apps/desktop/src/preload.cjs)给页面 `window.openfilmNative`, + * 里面每个方法都是 `ipcRenderer.invoke(channel, payload)`。这里换成 + * `POST /ipc/`(同源,面板自己的服务),推送走 `GET /ipc/events`(SSE)。 + * + * 只有三样是 Electron 独有的,网页里各有替身: + * · pickFolder —— 浏览器没有原生目录选择框(showDirectoryPicker),这里退成 null + * · revealPath —— 用 `open -R` 让访达显示它(走服务端) + * · capture —— 截自己的窗口在网页里没有意义,退成 null + */ +(function () { + /* 鉴权由 Host 做(MiniMax Code 3.1.2+ 的 loopback credential),页面不带任何密钥。 */ + + /** 一次调用:POST /ipc/,body 就是 preload 传给 invoke 的那个 payload。 */ + async function invoke(channel, payload) { + const r = await fetch('/ipc/' + encodeURIComponent(channel), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(enc(payload)), + }); + if (!r.ok) { + const t = await r.text().catch(() => ''); + throw new Error(t || ('channel ' + channel + ': ' + r.status)); + } + return dec(await r.json()); + } + + /** 二进制(ArrayBuffer / Uint8Array)在 JSON 里走 base64,不然会变成 {} */ + function enc(value) { + if (value === undefined) return null; + if (value instanceof ArrayBuffer) return { __b64: bytesToB64(new Uint8Array(value)) }; + if (ArrayBuffer.isView(value)) return { __b64: bytesToB64(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)) }; + if (Array.isArray(value)) return value.map(enc); + if (value && typeof value === 'object') { + const out = {}; + for (const [k, v] of Object.entries(value)) out[k] = enc(v); + return out; + } + return value; + } + function dec(value) { + if (value === null || value === undefined) return value; + if (Array.isArray(value)) return value.map(dec); + if (typeof value === 'object') { + if (typeof value.__b64 === 'string') return b64ToBytes(value.__b64); + const out = {}; + for (const [k, v] of Object.entries(value)) out[k] = dec(v); + return out; + } + return value; + } + function bytesToB64(bytes) { + let s = ''; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s); + } + function b64ToBytes(b64) { + const s = atob(b64); + const bytes = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) bytes[i] = s.charCodeAt(i); + return bytes.buffer; + } + + /** 订阅服务端的推送(desktop:local-agents:event 等)。 */ + const streams = new Map(); + function on(channel) { + return (listener) => { + if (typeof listener !== 'function') return () => {}; + let list = streams.get(channel); + if (!list) { list = new Set(); streams.set(channel, list); } + list.add(listener); + return () => list.delete(listener); + }; + } + /* 一条 SSE 连着所有频道:省得开几条 */ + const source = new EventSource('/ipc/events'); + source.onmessage = (e) => { + let msg; + try { msg = JSON.parse(e.data); } catch { return; } + const list = streams.get(msg.channel); + if (!list) return; + for (const listener of list) { try { listener(dec(msg.value)); } catch { /* 一个听众坏了不影响别的 */ } } + }; + + const noop = () => Promise.resolve({}); + const listeners = () => () => {}; + + window.openfilmNative = { + platform: 'darwin', + hello: () => invoke('desktop:hello'), + pathForFile: () => '', + revealPath: (path) => invoke('desktop:reveal', String(path)), + pickFolder: (options) => invoke('desktop:pick-folder', options ?? null), + capture: (rect) => invoke('desktop:capture', rect), + + tabs: { + list: () => invoke('desktop:tabs:list'), + onChange: on('desktop:tabs'), + show: (id) => invoke('desktop:tabs:show', { id }), + close: (id) => invoke('desktop:tabs:close', { id }), + open: (projectId) => invoke('desktop:tabs:new', { projectId }), + move: (id, index) => invoke('desktop:tabs:move', { id, index }), + report: (state) => invoke('desktop:tabs:report', state ?? {}), + gone: (projectIds) => invoke('desktop:tabs:gone', { projectIds }), + retitle: (id, title) => invoke('desktop:tabs:retitle', { id, title }), + openRequest: (to) => invoke('desktop:tabs:open', { to }), + onPick: on('desktop:tabs:pick'), + onCovered: on('desktop:tabs:covered'), + }, + + agents: { + list: () => invoke('desktop:agents:list'), + connect: (id) => invoke('desktop:agents:connect', { id }), + disconnect: (id) => invoke('desktop:agents:disconnect', { id }), + usage: (id, fresh) => invoke('desktop:agents:usage', { id, fresh: fresh === true }), + setShown: (id, shown) => invoke('desktop:agents:shown', { id, shown: shown === true }), + locate: (id, words) => invoke('desktop:agents:locate', { id, words }), + useAutomatic: (id) => invoke('desktop:agents:automatic', { id }), + install: (id) => invoke('desktop:agents:install', { id }), + diagnose: (id) => invoke('desktop:agents:diagnose', { id }), + report: () => invoke('desktop:agents:report'), + copyReport: () => invoke('desktop:diagnostics:copy'), + saveOwnModel: (config) => invoke('desktop:agents:own-model', { config }), + removeOwnModel: (id) => invoke('desktop:agents:own-model-remove', { id }), + mcp: (id) => invoke('desktop:agents:mcp', { id }), + skills: (id) => invoke('desktop:agents:skills', { id }), + allowSkill: (id, name, allowed) => invoke('desktop:skills:allow', { agent: id, name, allowed: allowed === true }), + allowMcp: (id, name, allowed) => invoke('desktop:agents:mcp-allow', { id, name, allowed: allowed === true }), + onChange: on('desktop:agents'), + }, + + localAgents: { + list: () => invoke('desktop:local-agents:list'), + signIn: (agent) => invoke('desktop:local-agents:sign-in', { agent }), + disconnect: (agent) => invoke('desktop:local-agents:disconnect', { agent }), + probe: (agent) => invoke('desktop:local-agents:probe', { agent }), + start: (agent, cwd, resume, meta) => invoke('desktop:local-agents:start', { agent, cwd, resume, meta }), + prompt: (key, text, meta, images) => invoke('desktop:local-agents:prompt', { key, text, meta, images }), + running: () => invoke('desktop:local-agents:running'), + ack: (key, turnId) => invoke('desktop:local-agents:ack', { key, turnId }), + steer: (key, text, images) => invoke('desktop:local-agents:steer', { key, text, images }), + adopt: (key, turnId, meta) => invoke('desktop:local-agents:adopt', { key, turnId, meta }), + cancel: (key) => invoke('desktop:local-agents:cancel', { key }), + setOption: (key, configId, value) => invoke('desktop:local-agents:set-option', { key, configId, value }), + answerPermission: (key, requestId, optionId) => invoke('desktop:local-agents:permission', { key, requestId, optionId }), + answerQuestion: (key, requestId, answers) => invoke('desktop:local-agents:answer', { key, requestId, answers }), + close: (key) => invoke('desktop:local-agents:close', { key }), + onEvent: on('desktop:local-agents:event'), + }, + + chat: { + load: (project) => invoke('desktop:chat:load', { project }), + saveSessions: (project, sessions, last) => invoke('desktop:chat:sessions', { project, sessions, last }), + saveTurn: (project, turn, items) => invoke('desktop:chat:turn', { project, turn, items }), + log: (project, turnId, unless) => invoke('desktop:chat:log', { project, turnId, unless }), + reveal: (project, turnId) => invoke('desktop:chat:reveal', { project, turnId }), + saveRefImage: (project, turnId, n, bytes) => invoke('desktop:chat:ref-image', { project, turnId, n, bytes }), + saveAttachment: (project, name, bytes) => invoke('desktop:chat:attachment', { project, name, bytes }), + describePaths: (paths) => invoke('desktop:chat:paths', { paths }), + unsaved: (project, except) => invoke('desktop:chat:unsaved', { project, except }), + snapshot: (project, turnId) => invoke('desktop:chat:snapshot', { project, turnId }), + changes: (project, turnId) => invoke('desktop:chat:changes', { project, turnId }), + }, + + skills: { + list: (project, agent) => invoke('desktop:skills:list', { project, agent }), + texts: (project, agent, names) => invoke('desktop:skills:texts', { project, agent, names }), + allow: (agent, name, allowed) => invoke('desktop:skills:allow', { agent, name, allowed }), + changed: () => invoke('desktop:skills:changed'), + preview: (url) => invoke('desktop:skills:preview', { url }), + previewFolder: (path, words) => invoke('desktop:skills:preview', { folder: typeof path === 'string' ? path : null, words }), + commit: (id, covers) => invoke('desktop:skills:commit', { id, covers }), + discard: (id) => invoke('desktop:skills:discard', { id }), + reveal: () => invoke('desktop:skills:reveal'), + }, + + prefs: { + get: () => invoke('desktop:prefs'), + set: (next) => invoke('desktop:prefs:set', next), + onChange: on('desktop:prefs'), + }, + + saveLayout: (layout) => invoke('desktop:layout', { chatOpen: layout?.chatOpen === true, chatWidth: Number(layout?.chatWidth) }), + setTheme: (theme) => invoke('desktop:theme', theme === 'dark' ? 'dark' : 'light'), + setLanguage: (language) => invoke('desktop:language', String(language)), + onFullScreen: on('desktop:full-screen'), + onToggleChat: on('desktop:toggle-chat'), + onOpenSettings: on('desktop:open-settings'), + onStudioBrowser: on('desktop:studio-browser'), + onFfmpeg: on('desktop:ffmpeg'), + onUpdate: on('desktop:update'), + restartToUpdate: () => invoke('desktop:update-restart'), + updateState: () => invoke('desktop:update-state'), + checkForUpdate: () => invoke('desktop:update-check'), + }; +})(); diff --git a/plugins/cristinamadureira353-svg/openfilm/miniapp/client/index.html b/plugins/cristinamadureira353-svg/openfilm/miniapp/client/index.html new file mode 100644 index 00000000..ad204708 --- /dev/null +++ b/plugins/cristinamadureira353-svg/openfilm/miniapp/client/index.html @@ -0,0 +1,143 @@ + + + + + + OpenFilm + + + +
+ +
+ 正在起 OpenFilm… +
+ +
+
+ + + + diff --git a/plugins/cristinamadureira353-svg/openfilm/miniapp/miniapp.json b/plugins/cristinamadureira353-svg/openfilm/miniapp/miniapp.json new file mode 100644 index 00000000..498d880a --- /dev/null +++ b/plugins/cristinamadureira353-svg/openfilm/miniapp/miniapp.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "artifacts": { + "client": [ + "./miniapp/client" + ], + "node": [ + "./miniapp/node" + ] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/dashboard" + }, + "mcpEndpoints": [] +} diff --git a/plugins/cristinamadureira353-svg/openfilm/miniapp/node/desktop-host.mjs b/plugins/cristinamadureira353-svg/openfilm/miniapp/node/desktop-host.mjs new file mode 100644 index 00000000..c9cd5c08 --- /dev/null +++ b/plugins/cristinamadureira353-svg/openfilm/miniapp/node/desktop-host.mjs @@ -0,0 +1,299 @@ +/** + * 桌面版的「主进程」,搬成 HTTP。 + * + * 桌面 app 的页面(apps/desktop/shell)靠 `window.openfilmNative` 说话,而那套接口(preload.cjs) + * 每个方法都是 `ipcRenderer.invoke(channel, payload)`;真正做事的是主进程里的 handler。 + * + * 这里的做法:**把那些 handler 原样拿来用**(它们是纯 Node 函数,见 apps/desktop/src/*.mjs), + * 只是把 `ipcMain` 换成一个 HTTP 版的替身: + * + * · handle(channel, fn) → POST /ipc/ 时调 fn(event, payload) + * · event.sender → 一个假的 webContents:send() 把消息推给页面的 SSE + * + * handler 自己不 import electron(chat-ipc / local-agents-ipc / skills-ipc 都没有), + * 所以它们能原样跑在这里。 + */ +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { readFile } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; + +/** + * 一个 ipcMain 形状的替身:handler 收进 Map,页面来调时按频道找。 + * `send` 的频道(fire-and-forget)也收进来,页面那边用同一个 invoke 调。 + */ +export function createIpcMain() { + /** @type {Map any>} */ + const handlers = new Map(); + return { + handle(channel, fn) { handlers.set(channel, fn); }, + on(channel, fn) { handlers.set(channel, fn); }, + get(channel) { return handlers.get(channel); }, + has(channel) { return handlers.has(channel); }, + channels() { return [...handlers.keys()]; }, + }; +} + +/** JSON 里的二进制(ArrayBuffer / Uint8Array)走 base64,不然会变成 {}。 */ +export function enc(value) { + if (value === undefined) return null; + if (value instanceof ArrayBuffer) return { __b64: bytesToB64(new Uint8Array(value)) }; + if (ArrayBuffer.isView(value)) return { __b64: bytesToB64(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)) }; + if (Array.isArray(value)) return value.map(enc); + if (value && typeof value === 'object') { + const out = {}; + for (const [k, v] of Object.entries(value)) out[k] = enc(v); + return out; + } + return value; +} +export function dec(value) { + if (value === null || value === undefined) return value; + if (Array.isArray(value)) return value.map(dec); + if (typeof value === 'object') { + if (typeof value.__b64 === 'string') return b64ToBytes(value.__b64); + const out = {}; + for (const [k, v] of Object.entries(value)) out[k] = dec(v); + return out; + } + return value; +} +function bytesToB64(bytes) { + let s = ''; + for (const b of bytes) s += String.fromCharCode(b); + return Buffer.from(s, 'binary').toString('base64'); +} +function b64ToBytes(b64) { + return Buffer.from(b64, 'base64').buffer.slice(0); +} + +/** + * 把桌面 app 的 handler 接上。 + * + * @param {object} options + * @param {string} options.repoDir OpenFilm 仓库(桌面 app 的 src 在 apps/desktop/src) + * @param {string} options.dataDir 这个 Mini App 自己的 home(prefs、日志) + * @param {string} options.studioOrigin Studio 的地址(项目的列表从它那儿问) + * @param {string} options.studioKey Studio 的 key + * @param {(channel: string, value: unknown) => void} options.tell 把消息推给页面 + * @param {string} options.openfilmHome agent 跑起来时的 OPENFILM_HOME(它要找到同一个 Studio) + * @param {object} [options.logger] context.logger(stdout 属于 Host,不能 console.log) + */ +export async function registerDesktopIpc(ipcMain, options) { + const { repoDir, dataDir, studioOrigin, studioKey, tell, openfilmHome } = options; + const src = join(repoDir, 'apps/desktop/src'); + const load = async (file) => import(pathToFileURL(join(src, file)).href); + + const { registerChatIpc, readPreferences } = await load('chat-ipc.mjs'); + const { registerLocalAgentIpc } = await load('local-agents-ipc.mjs'); + const { registerSkillsIpc } = await load('skills-ipc.mjs'); + + const log = (message) => { try { options.logger?.info?.(`[openfilm-desktop] ${message}`); } catch { /* 日志不是关键路径 */ } }; + + /** Studio 记得的项目(chat 与 skills 只认这些文件夹)。 */ + const projects = async () => { + try { + const r = await fetch(studioOrigin + '/api/projects', { headers: { 'x-studio-key': studioKey } }); + const body = await r.json(); + return (body.projects || []).filter((p) => !p.missing).map((p) => p.path); + } catch { + return []; + } + }; + + /** 访达里显示它:Electron 是 shell.showItemInFolder,这里用 `open -R`。 */ + const reveal = (path) => { + try { spawn('open', ['-R', String(path)], { detached: true, stdio: 'ignore' }).unref(); } catch { /* 找不到就算了 */ } + }; + + /** 人挑的文件夹:网页里没有原生对话框,先退成「没挑」。 */ + const pickFolder = () => null; + + /* 桌面的 prefs 在它自己的 home 里;这里用自己的。 */ + const prefsHome = openfilmHome; + + registerChatIpc(ipcMain, { + trusted: () => true, + projects, + dataDir, + reveal, + tell, + log, + }); + + const localAgents = registerLocalAgentIpc(ipcMain, { + trusted: () => true, + log, + onWorking: (folders) => tell('desktop:working', folders), + onAsking: (folders) => tell('desktop:asking', folders), + binDir: null, + fallbackDir: null, + env: { OPENFILM_HOME: openfilmHome }, + harness: async () => null, + logDir: () => (readPreferences(dataDir).developer ? join(dataDir, 'agent-logs') : null), + skills: { dataDir, mine: join(openfilmHome, 'skills'), prefsHome }, + }); + + registerSkillsIpc(ipcMain, { + trusted: () => true, + projects, + dataDir, + mine: join(openfilmHome, 'skills'), + prefsHome, + oldMine: join(openfilmHome, 'skills'), + reveal, + pickFolder, + refreshSkills: (agent) => localAgents?.refreshSkills(agent), + log, + }); + + /* 下面这些是 app 自己的(窗口、标签、更新、登录)——网页里没有,就照实说没有。 */ + ipcMain.handle('desktop:hello', () => ({ + flavor: 'dev', + version: '0.2.5', + ffmpeg: null, + update: null, + chatOpen: true, + chatWidth: 420, + chatMin: 320, + studioMin: 480, + fullScreen: false, + })); + ipcMain.handle('desktop:layout', () => ({ ok: true })); + ipcMain.handle('desktop:theme', () => ({ ok: true })); + ipcMain.handle('desktop:language', () => ({ ok: true })); + ipcMain.handle('desktop:reveal', (event, path) => { reveal(path); return { ok: true }; }); + ipcMain.handle('desktop:pick-folder', () => null); + ipcMain.handle('desktop:capture', () => null); + ipcMain.handle('desktop:update-restart', () => ({ ok: false, error: 'the desktop app does updates, not this panel' })); + ipcMain.handle('desktop:update-state', () => null); + ipcMain.handle('desktop:update-check', () => null); + ipcMain.handle('desktop:full-screen', () => ({ ok: true })); + ipcMain.handle('desktop:toggle-chat', () => ({ ok: true })); + ipcMain.handle('desktop:open-settings', () => ({ ok: true })); + ipcMain.handle('desktop:studio-browser', () => ({ ok: true })); + ipcMain.handle('desktop:ffmpeg', () => ({ ok: true })); + + /* 窗口的标签:面板里只有一个页面,它就是那一个标签。 */ + ipcMain.handle('desktop:tabs:list', () => ({ self: 'panel', tabs: [] })); + ipcMain.handle('desktop:tabs:new', () => ({ ok: true })); + ipcMain.handle('desktop:tabs:show', () => ({ ok: true })); + ipcMain.handle('desktop:tabs:close', () => ({ ok: true })); + ipcMain.handle('desktop:tabs:move', () => ({ ok: true })); + ipcMain.handle('desktop:tabs:report', () => ({ ok: true })); + ipcMain.handle('desktop:tabs:gone', () => ({ ok: true })); + ipcMain.handle('desktop:tabs:retitle', () => ({ ok: true })); + ipcMain.handle('desktop:tabs:open', () => ({ ok: true })); + + /* Settings → Agents 和聊天的 agent 菜单用的是**另一份**名单(roster),不是 localAgents。 + 之前这里被 stub 成空数组,所以「你的智能体」一个都没有 —— 现在接真的。 + 这些模块(agent-roster / agents / agent-usage / app-report)都不 import electron。 */ + const { createRoster } = await load('agent-roster.mjs'); + const { + listAgents, localAgent, openAgentSignIn, openAgentInstaller, + setChosenPaths, checkAgentProgram, diagnoseAgent, + } = await load('agents.mjs'); + const { agentUsage, forgetAgentUsage } = await load('agent-usage.mjs'); + const { gatherAppReport } = await load('app-report.mjs'); + + const roster = createRoster({ + dataDir, + localAgents: listAgents, + localAgent, + openSignIn: openAgentSignIn, + openInstaller: openAgentInstaller, + closeSessions: (id) => { forgetAgentUsage(id); localAgents?.closeAgent(id); }, + seal: (text) => sealKey(dataDir, text), + unseal: (data) => unsealKey(dataDir, data), + onChange: (list) => tell('desktop:agents', list), + usePaths: setChosenPaths, + /* 网页里没有原生文件对话框:让系统去找,不让人手点路径 */ + pickProgram: async () => null, + checkProgram: checkAgentProgram, + diagnose: (id) => diagnoseAgent(id, { appVersion: 'miniapp' }), + }); + + ipcMain.handle('desktop:agents:list', () => { void roster.refresh(); return roster.list(); }); + ipcMain.handle('desktop:agents:connect', (event, { id } = {}) => roster.connect(String(id))); + ipcMain.handle('desktop:agents:disconnect', (event, { id } = {}) => { roster.disconnect(String(id)); return { ok: true }; }); + ipcMain.handle('desktop:agents:usage', (event, { id, fresh } = {}) => agentUsage(String(id), { fresh: fresh === true })); + ipcMain.handle('desktop:agents:locate', (event, { id, words } = {}) => roster.locate(String(id), words)); + ipcMain.handle('desktop:agents:automatic', (event, { id } = {}) => roster.useAutomatic(String(id))); + ipcMain.handle('desktop:agents:install', (event, { id } = {}) => roster.install(String(id))); + ipcMain.handle('desktop:agents:diagnose', (event, { id } = {}) => roster.diagnose(String(id))); + ipcMain.handle('desktop:agents:shown', (event, { id, shown } = {}) => { roster.setShown(String(id), shown === true); return { ok: true }; }); + ipcMain.handle('desktop:agents:own-model', (event, { config } = {}) => roster.saveOwnModel(config)); + ipcMain.handle('desktop:agents:own-model-remove', (event, { id } = {}) => { roster.removeOwnModel(String(id)); return { ok: true }; }); + + const appReport = () => gatherAppReport( + { list: () => roster.refresh(), check: checkAgentProgram }, + { version: 'miniapp', flavor: 'dev', packaged: false, channel: null, electron: null, pathSource: 'miniapp' }, + ); + ipcMain.handle('desktop:agents:report', appReport); + /* 「复制诊断报告」:网页里不让写剪贴板的地方,走 pbcopy(macOS)。 */ + ipcMain.handle('desktop:diagnostics:copy', async () => { + const [computer, own] = await Promise.all([ + studioGet(studioOrigin, studioKey, '/api/doctor').then((r) => String(r.text ?? ''), () => ''), + appReport().catch(() => ''), + ]); + const text = [computer, own].filter(Boolean).join('\n\n────────\n\n'); + if (!text) return { ok: false }; + const copied = await new Promise((resolve) => { + const child = spawn('pbcopy', [], { stdio: ['pipe', 'ignore', 'ignore'] }); + child.on('error', () => resolve(false)); + child.on('close', (code) => resolve(code === 0)); + child.stdin.end(text); + }); + return { ok: copied }; + }); + + return { localAgents, roster }; +} + +/** 一次 studio 调用(项目的列表、prefs 之类)。 */ +export async function studioGet(studioOrigin, studioKey, path) { + const r = await fetch(studioOrigin + path, { headers: { 'x-studio-key': studioKey } }); + return r.json(); +} + +/** 读一个文件(探测仓库在不在)。 */ +export async function exists(path) { + try { await readFile(path); return true; } catch { return false; } +} + +/** + * 自有密钥(BYOK)的封存。 + * + * 桌面 app 用系统钥匙串(Electron 的 safeStorage);面板不是 Electron,没有它。 + * 这里用一份只在本机、0600 的主密钥做 AES-256-GCM:密钥不落明文,但**不是钥匙串**。 + * 面板的数据目录是自己的(~/.minimax/openfilm-studio),跟桌面 app 的不是同一份, + * 所以两边存的密钥互不相通(换一边用要重新填)。 + */ +function masterKey(dataDir) { + const file = join(dataDir, 'byok-master-key'); + if (existsSync(file)) return readFileSync(file); + mkdirSync(dataDir, { recursive: true }); + const key = randomBytes(32); + writeFileSync(file, key, { mode: 0o600 }); + return key; +} + +function sealKey(dataDir, text) { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', masterKey(dataDir), iv); + return Buffer.concat([Buffer.from('enc:'), iv, cipher.update(text, 'utf8'), cipher.final(), cipher.getAuthTag()]); +} + +function unsealKey(dataDir, data) { + const kind = data.subarray(0, 4).toString(); + if (kind === 'raw:') return data.subarray(4).toString(); + const body = data.subarray(4); + const iv = body.subarray(0, 12); + const tag = body.subarray(body.length - 16); + const encrypted = body.subarray(12, body.length - 16); + const decipher = createDecipheriv('aes-256-gcm', masterKey(dataDir), iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8'); +} diff --git a/plugins/cristinamadureira353-svg/openfilm/miniapp/node/server.mjs b/plugins/cristinamadureira353-svg/openfilm/miniapp/node/server.mjs new file mode 100644 index 00000000..8a04a704 --- /dev/null +++ b/plugins/cristinamadureira353-svg/openfilm/miniapp/node/server.mjs @@ -0,0 +1,1080 @@ +// @ts-check +/** + * OpenFilm Mini App —— Node 运行时锚点。 + * + * 这个 Mini App 里跑的就是 OpenFilm 本体:客户端是一层壳(项目列表 + 一个 iframe), + * iframe 指向 OpenFilm Studio 的页面。Studio 是 OpenFilm 自己的服务,本 Mini App 不重画编辑器, + * 只负责把它跑起来、拿到它的地址与密钥,并把项目树交给客户端。 + * + * GET / 客户端页面(surface.path = /dashboard 同样服务) + * GET /api/studio { ready, origin, key, mode, error }:给客户端拼 iframe 的 src + * GET /api/tree 项目树:{ groups: [{ project, children: [...] }] } + * GET /api/health 本进程与 Studio 的状态(诊断用) + * + * ## Studio 从哪儿来 + * + * **自己起一份纯浏览器的 Studio**(`studio/ui/dist`,不依赖 window.openfilmNative), + * 用**自己的 home 与端口**(`~/.minimax/openfilm-studio`、4823)。 + * + * 为什么不用用户那份 OpenFilm 的 Studio: + * · 桌面 app 的 Studio 伺服的是 **Electron 专用**的编辑器(依赖 window.openfilmNative), + * 那个页面在普通浏览器里是白的 —— 而面板嵌的就是浏览器(实测:黑屏 + TypeError); + * · 共用 home 的话,这份 Studio 会把用户的 run.json 抢走,用户的 OpenFilm 就找不着自己的 Studio 了。 + * + * ## 项目从哪儿来 + * + * 读**用户那份** Studio home 里的 `studio.json`(只读,不写):那里是 Studio 记得的项目, + * 也就是用户正式版 OpenFilm 的项目列表。 + * 另外把自己这份 Studio 记得的项目并进来 —— 面板里新建的项目要能在面板的列表里看见。 + * 嵌套关系自己算:路径是另一个的前缀 ⇒ 它在那个项目文件夹里面(与 Studio 的 `parentProject` + * 同一个规矩:取最长的那个前缀)。官方版把子文件夹也列成平级的项目,这里把它缩在父项目下面。 + * 项目 id 在各项目的 `.film/id` 里(Studio 算的,同一个文件夹永远是同一个 id)。 + */ +import { readFile, stat, mkdir, open, readdir } from 'node:fs/promises'; +import { createServer, request as httpRequest } from 'node:http'; +import { spawn } from 'node:child_process'; +import net from 'node:net'; +import { join, dirname, basename, extname, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; +import { createIpcMain, registerDesktopIpc, enc, dec } from './desktop-host.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); + +/** OpenFilm 的 Studio 打印地址的那一行:`Studio http://127.0.0.1:4747/open?key=…&to=…`。 */ +const ADDRESS_LINE = /Studio\s+(\S+)/; + +/** Studio 起来要多久(它是 Node + 浏览器渲染,冷启动不慢)。 */ +const BOOT_TIMEOUT_MS = 30_000; +/** 探一个已经跑着的 Studio:它有多快应答。 */ +const PROBE_TIMEOUT_MS = 6_000; + +/** + * 这个 Mini App 自己那份 Studio 的 home 与端口:**跟用户那份分开**。 + * 自己的 home 意味着它写自己的 run.json(不抢用户的),自己的端口意味着它不跟谁撞。 + */ +const DEFAULT_STUDIO_HOME = join(homedir(), '.minimax', 'openfilm-studio'); +const DEFAULT_STUDIO_PORT = 4823; + +/** 用户那份 Studio 的 home:项目(recent)在它的 studio.json 里。 */ +const USER_STUDIO_HOMES = { + release: join(homedir(), '.openfilm'), + dev: join(homedir(), '.openfilm-dev'), +}; + +/** 库根目录:正式版是 ~/Movies/OpenFilm/Projects(OpenFilm 自己的默认),Dev 版是它旁边的那个。 */ +const LIBRARY_ROOTS = { + release: join(homedir(), 'Movies', 'OpenFilm', 'Projects'), + dev: join(homedir(), 'Movies', 'OpenFilm Dev', 'Projects'), +}; + +/** 客户端页面:包内自带,零构建。 */ +const CLIENT_FILE = join(here, '..', 'client', 'index.html'); +/** 网页版的 openfilmNative 替身(桌面版页面的原生桥,改走 HTTP)。 */ +const BRIDGE_WEB_FILE = join(here, '..', 'client', 'bridge-web.js'); +/** 配置:Host 启动 Mini App 时无法注入环境变量,所以绝对路径写在这个文件里。 */ +const CONFIG_FILE = 'miniapp.config.json'; + +/** 默认跑 npm 上的正式发布版(= GitHub 官方仓库的发布版),不用 PATH 上那个可能更旧的。 */ +const DEFAULT_OPENFILM_BIN = 'npx'; +const DEFAULT_OPENFILM_ARGS = ['-y', 'openfilm@0.2.5']; + +/** 桌面版模式:把桌面 app 的界面(编辑器 + 聊天栏)跑在面板里。 */ +const DEFAULT_OPENFILM_REPO = join(homedir(), 'Developer/openfilm'); + +/** 读配置:读不到就用默认值(配置只是让人能改,不该成为启动的前提)。 */ +async function readConfig(pluginRoot) { + const read = async (file) => { + try { + const parsed = JSON.parse(await readFile(join(pluginRoot, file), 'utf8')); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } + }; + /* miniapp.config.local.json 是自己机器上的覆盖(比如你这台的 Dev 版路径), + 不进共享配置、不该被提交 —— 开源出去的包里没有它也能跑。 */ + return { ...(await read(CONFIG_FILE)), ...(await read('miniapp.config.local.json')) }; +} + +/** 配置里的路径:`~` 当作家目录。 */ +function expandHome(path) { + const value = String(path || ''); + return value.startsWith('~') ? join(homedir(), value.slice(1)) : value; +} + +/** 探一个 Studio:它的 /api/health 会不会答。答了就说明地址与 key 都还管用。 */ +function probeStudio(origin, key, timeoutMs = PROBE_TIMEOUT_MS) { + return new Promise((resolve) => { + const req = httpRequest( + { host: '127.0.0.1', port: new URL(origin).port, path: '/api/health', method: 'GET', timeout: timeoutMs, agent: false }, + (response) => { + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + let parsed = null; + try { parsed = JSON.parse(body); } catch { /* 不是 JSON 也不是它 */ } + resolve({ ok: response.statusCode === 200 && Boolean(parsed?.product), status: response.statusCode, body: body.slice(0, 200) }); + }); + }, + ); + req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'timeout' }); }); + req.on('error', (error) => resolve({ ok: false, error: String(error.message || error).slice(0, 120) })); + req.end(); + }); +} + +/** 读 run.json:Studio 自己写的,里头有 origin 与 key。 */ +async function readRunFile(path) { + try { + const raw = JSON.parse(await readFile(path, 'utf8')); + if (raw?.origin && raw?.key) return { origin: String(raw.origin), key: String(raw.key), pid: raw.pid ?? null, filmOrigin: raw.filmOrigin ? String(raw.filmOrigin) : null }; + } catch { /* 没有,或者不是它的 */ } + return null; +} + +/** 从 Studio 打印的地址里取出 origin 与 key。 */ +function parseStudioAddress(address) { + try { + const url = new URL(address); + const key = url.searchParams.get('key'); + if (!key) return null; + return { origin: url.origin, key, to: url.searchParams.get('to') }; + } catch { + return null; + } +} + +/** 杀一个进程树(先温和后强硬)。只对自己 spawn 的用。 */ +function killTree(child) { + if (!child?.pid) return; + try { process.kill(-child.pid, 'SIGTERM'); } catch { /* 已经没了,或者不是组长 */ } + try { process.kill(child.pid, 'SIGTERM'); } catch { /* 已经没了 */ } + setTimeout(() => { + try { process.kill(-child.pid, 'SIGKILL'); } catch { /* 已经没了 */ } + try { process.kill(child.pid, 'SIGKILL'); } catch { /* 已经没了 */ } + }, 1500).unref?.(); +} + +/** + * 一个路径在这个库里吗(库根自己或它下面)。 + * 只用来标出「这个项目在哪套库里」,不拿来过滤(面板列的是 Studio 记得的项目)。 + */ +function insideLibrary(path, root) { + if (!path || !root) return false; + const target = String(path); + return target === root || target.startsWith(root + '/'); +} + +export async function start(context) { + const { pluginRoot, listen, signal, logger } = context; + const config = await readConfig(pluginRoot); + + /* 这个实例自己那份 Studio 的 home 与端口:默认放在 Host 给的 dataDir 里(仓库硬规则: + 持久状态归 context.dataDir),不抢用户桌面 app 的 home。 + 测试可以用 miniapp.config.json 的 studioHome / studioPort 指到临时目录与别的端口。 */ + const STUDIO_HOME = expandHome(config.studioHome + || (context.dataDir ? join(context.dataDir, 'openfilm-studio') : DEFAULT_STUDIO_HOME)); + const STUDIO_PORT = Number(config.studioPort) > 0 ? Number(config.studioPort) : DEFAULT_STUDIO_PORT; + + /* 鉴权交给 Host:MiniMax Code 3.1.2 起,Host 在这个端口上装了每次运行一个凭据的检查, + 没凭据的请求(含直连与 WebSocket)一律 401,根本到不了这里。 + 所以这里**不自己造 token**,也不把任何密钥注进页面(仓库硬规则:密钥不出 Node 进程)。 + 见 MiniMax-Code-MiniApps 的 docs/runtime.md「Loopback authentication」。 */ + + /** + * 读哪一份 Studio home 的项目列表。 + * 默认 release:用户日常在用的是正式版(~/.openfilm + ~/Movies/OpenFilm/Projects)。 + */ + const flavor = config.flavor === 'dev' ? 'dev' : 'release'; + const userHome = USER_STUDIO_HOMES[flavor]; + const openfilmBin = config.openfilmBin || DEFAULT_OPENFILM_BIN; + + /** + * 桌面版模式:把桌面 app 的界面(编辑器 + 右边那栏聊天)跑在面板里。 + * + * MiniApp 只能是网页,Electron 的窗口整体塞不进来;但桌面版的**界面**是网页(它自己是 + * Studio 伺服的一页),缺的只是 `window.openfilmNative` 那套原生桥。所以这里伺服它那一页, + * 并把桥换成走 HTTP 的那一份。 + * + * 桌面 app 的那些 handler 不在 npm 包里(`@openfilm/desktop` 没发布),只在这个仓库里, + * 所以这一模式要指到仓库(openfilmRepo)。 + */ + const desktop = { + on: config.desktopMode === true, + repoDir: expandHome(config.openfilmRepo || process.env.OPENFILM_REPO || DEFAULT_OPENFILM_REPO), + shellDist: '', + ipc: null, + sender: null, + ready: false, + loading: false, + error: null, + streams: new Set(), + }; + desktop.shellDist = expandHome(config.shellDist || join(desktop.repoDir, 'apps/desktop/shell/dist')); + + /** 面板这一侧的消息(页面的推送):SSE 推给浏览器。 */ + const tell = (channel, value) => { + const frame = `data: ${JSON.stringify({ channel, value: enc(value) })}\n\n`; + for (const stream of desktop.streams) { + try { stream.write(frame); } catch { /* 断了就断了 */ } + } + }; + const openfilmArgs = Array.isArray(config.openfilmArgs) && config.openfilmArgs.length + ? config.openfilmArgs + : DEFAULT_OPENFILM_ARGS; + + /** Studio 的现状:reused(我们上次起的,还在跑)还是 spawned(这次起的)。 */ + const state = { + mode: 'unknown', // 'reused' | 'spawned' + origin: null, + key: null, + child: null, + ready: false, + error: null, + startedAt: Date.now(), + readyAt: null, + bootLog: [], + }; + + const log = (message) => { + state.bootLog.push(String(message).slice(0, 200)); + if (state.bootLog.length > 30) state.bootLog.shift(); + try { logger?.info?.(`[openfilm] ${message}`); } catch { /* 日志不是关键路径 */ } + }; + + /** + * 起一份 Studio(或者接上我们上次起的那份)。 + * + * 走 `openfilm open`:它认自己的 home,会把地址打到 stdout。 + * 但它会**复用**已经跑着的那个(按它自己 home 的 run.json)—— 那可能是用户桌面 app 的 + * Studio(Electron 专用编辑器,浏览器里是白的)。所以先探:能答、且不是 app 自己的,才复用。 + */ + async function ensureStudio() { + if (state.ready) return; + + const run = await readRunFile(join(STUDIO_HOME, 'run.json')); + if (run) { + const probe = await probeStudio(run.origin, run.key); + if (probe.ok) { + state.mode = 'reused'; + state.origin = run.origin; + state.key = run.key; + state.ready = true; + state.readyAt = Date.now(); + log(`reused the Studio at ${run.origin}`); + return; + } + log(`the Studio at ${run.origin} did not answer (${probe.error || probe.status}); starting one`); + } + + // 自己起一个。`openfilm open` 会把带 key 的地址打到 stdout。 + // 自己的 home ⇒ 它写自己的 run.json,不抢用户的;自己的库 ⇒ 它跟用户那份是两份数据。 + // + // **两个面板同时开时只许起一个**(它们抢同一个端口、同一份 run.json): + // 锁文件放在自己那份 home 里,谁先拿到锁谁起,另一个等它起来后复用。 + await mkdir(STUDIO_HOME, { recursive: true }).catch(() => {}); + const lock = join(STUDIO_HOME, 'starting.lock'); + /** 已经有能用的 Studio 就用它,不起新的。 */ + const adopt = async (run, why) => { + if (!run) return false; + const probe = await probeStudio(run.origin, run.key); + if (!probe.ok) return false; + state.mode = 'reused'; + state.origin = run.origin; + state.key = run.key; + state.ready = true; + state.readyAt = Date.now(); + log(`reused the Studio at ${run.origin}${why ? ` (${why})` : ''}`); + return true; + }; + /** 拿锁:拿到了才轮得到起。 */ + const take = async () => { + try { + // 'wx':文件已存在就失败(锁在别人手里) + const handle = await open(lock, 'wx'); + await handle.writeFile(String(process.pid)); + await handle.close(); + return true; + } catch { + return false; + } + }; + + // 先看有没有能用的(前一个面板留下的) + if (await adopt(await readRunFile(join(STUDIO_HOME, 'run.json')), 'left by an earlier panel')) return; + + // 没有就抢锁起一个。**两个面板同时开时只许起一个**(它们抢同一个端口、同一份 run.json)。 + let got = false; + for (let attempt = 0; attempt < 60; attempt++) { + if (await take()) { got = true; break; } + // 锁在别人手里:他正起。等一下看他起来没有。 + await new Promise((done) => setTimeout(done, 500)); + if (await adopt(await readRunFile(join(STUDIO_HOME, 'run.json')), 'another panel started it')) return; + } + if (!got) { + state.error = 'Another panel is starting OpenFilm; try again in a moment.'; + log(state.error); + return; + } + // 拿到锁了:可能刚刚有人起来又退了 —— 再看一眼,别重复起。 + if (await adopt(await readRunFile(join(STUDIO_HOME, 'run.json')), 'started while we waited')) { + await releaseLock(); + return; + } + + const child = spawn(openfilmBin, [...openfilmArgs, 'open'], { + cwd: here, + env: { + ...process.env, + OPENFILM_NO_BROWSER: '1', + OPENFILM_HOME: STUDIO_HOME, + OPENFILM_STUDIO_PORT: String(STUDIO_PORT), + }, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + }); + state.mode = 'spawned'; + state.child = child; + log(`started openfilm (pid ${child.pid})`); + + let out = ''; + const onData = (chunk) => { + out += String(chunk); + const match = out.match(ADDRESS_LINE); + if (match) { + const parsed = parseStudioAddress(match[1]); + if (parsed) { + state.origin = parsed.origin; + state.key = parsed.key; + state.ready = true; + state.readyAt = Date.now(); + state.error = null; + log(`the Studio is at ${parsed.origin}`); + } + } + }; + child.stdout?.on('data', onData); + child.stderr?.on('data', (chunk) => { out += String(chunk); log(String(chunk).trim()); }); + child.on('error', (error) => { state.error = String(error.message || error); log(`openfilm failed: ${state.error}`); }); + child.on('exit', (code) => { + log(`openfilm exited (${code})`); + if (!state.ready) state.error = state.error || `openfilm exited (${code})`; + }); + + // 等它把地址吐出来 + const until = Date.now() + BOOT_TIMEOUT_MS; + while (!state.ready && Date.now() < until) { + await new Promise((done) => setTimeout(done, 250)); + } + if (!state.ready) { + state.error = state.error || `OpenFilm did not answer within ${Math.round(BOOT_TIMEOUT_MS / 1000)} s`; + log(state.error); + } + // 锁用完了就放开(Studio 起来了,它自己的 run.json 就是别人的凭据了) + await releaseLock(); + } + + /** 放开起 Studio 的锁(谁拿到锁谁负责放开)。 */ + async function releaseLock() { + try { + const { unlink } = await import('node:fs/promises'); + await unlink(join(STUDIO_HOME, 'starting.lock')); + } catch { /* 已经没了 */ } + } + + /** + * 客户端要的项目树。 + * + * 项目从**用户那份 Studio home** 的 studio.json 来(Studio 记得的,就是它列表里那些), + * 再并上**面板自己这份 Studio** 记得的 —— 面板里新建的项目得能在面板的列表里看见。 + * 嵌套关系自己算:路径是另一个的前缀 ⇒ 它在那个项目文件夹里面(与 Studio 的 parentProject + * 同一个规矩:最长的那个前缀算父)。项目 id 从各项目的 .film/id 读(Studio 算的, + * 同一个文件夹永远同一个 id)。 + */ + async function projectTree() { + if (!state.ready || !state.origin || !state.key) { + return { groups: [], library: flavor, error: state.error || 'OpenFilm is not up yet' }; + } + const recent = []; + for (const home of [userHome, STUDIO_HOME]) { + const raw = await readFile(join(home, 'studio.json'), 'utf8').catch(() => null); + if (raw === null) { + // 用户那份读不到是真的读不了;自己这份还没有是正常的(还没开过项目) + if (home === userHome) return { groups: [], library: flavor, error: 'the project list could not be read' }; + continue; + } + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed?.recent)) recent.push(...parsed.recent); + } catch { + if (home === userHome) return { groups: [], library: flavor, error: 'the project list could not be read' }; + } + } + const projects = []; + const seenPaths = new Set(); + for (const r of recent) { + if (!r?.path) continue; + if (seenPaths.has(r.path)) continue; // 两份列表里同一个项目只算一个 + const id = await projectId(r.path); + if (!id) continue; // 文件夹没了的项目不列(跟官方列表一致:它标 missing) + seenPaths.add(r.path); + projects.push({ + id, + path: r.path, + name: r.path.split('/').pop() || r.path, + library: insideLibrary(r.path, LIBRARY_ROOTS.release) ? 'release' : insideLibrary(r.path, LIBRARY_ROOTS.dev) ? 'dev' : 'elsewhere', + }); + } + /** + * 嵌套:路径是另一个项目的前缀 ⇒ 它在那个项目文件夹里面。 + * 取**最长**的那个前缀(Studio 的 parentProject 也是这个规矩:`未命名/子项目` 的父是 + * `未命名`,不是 `未命名 2` —— 路径的前缀得带分隔符才算)。 + */ + const parentOf = new Map(); + const childrenOf = new Map(); + for (const p of projects) { + let parent = null; + for (const other of projects) { + if (other.id === p.id || other.path === p.path) continue; + if (!p.path.startsWith(other.path + sep)) continue; + if (!parent || other.path.length > parent.path.length) parent = other; + } + if (!parent) continue; + parentOf.set(p.id, parent.id); + const list = childrenOf.get(parent.id) || []; + list.push(p); + childrenOf.set(parent.id, list); + } + const groups = []; + const done = new Set(); + const collect = (project, depth, into) => { + if (done.has(project.id)) return; + done.add(project.id); + for (const child of childrenOf.get(project.id) || []) { + into.push({ project: child, depth: depth + 1 }); + collect(child, depth + 1, into); + } + }; + for (const p of projects) { + if (parentOf.has(p.id)) continue; // 它在别人的文件夹里,不是头 + if (done.has(p.id)) continue; + const children = []; + collect(p, 0, children); + groups.push({ project: p, children }); + } + for (const p of projects) { + if (done.has(p.id)) continue; + const children = []; + collect(p, 0, children); + groups.push({ project: p, children }); + } + return { groups, library: flavor }; + } + + /** 项目自己的 id(Studio 算的,存在项目的 .film/id 里)。 */ + async function projectId(path) { + try { + const id = (await readFile(join(path, '.film', 'id'), 'utf8')).trim(); + return /^\w{6,40}$/.test(id) ? id : null; + } catch { + return null; + } + } + + /** + * 把桌面 app 的 handler 接上(Studio 起来之后)。只接一次。 + * + * 那些 handler 在仓库里(apps/desktop/src/*.mjs,`@openfilm/desktop` 没发布到 npm)。 + * 它们是纯 Node 函数,只是拿了个 ipcMain 形状注册 —— 这里给它们一个 HTTP 版的。 + */ + async function ensureDesktopHost() { + if (!desktop.on) return false; + if (desktop.ready) return true; + if (desktop.loading) return false; + if (!state.ready || !state.origin || !state.key) return false; + desktop.loading = true; + try { + const shell = await stat(join(desktop.shellDist, 'index.html')).catch(() => null); + if (!shell) throw new Error(`桌面版的页面不在 ${desktop.shellDist}(先在仓库里跑一次 build:shell)`); + const ipc = createIpcMain(); + /* 假的 webContents:它的 send() 把消息推给页面的 SSE */ + desktop.sender = { + isDestroyed: () => false, + send: (channel, value) => tell(channel, value), + once: () => {}, + on: () => {}, + removeListener: () => {}, + }; + const host = await registerDesktopIpc(ipc, { + repoDir: desktop.repoDir, + dataDir: STUDIO_HOME, + studioOrigin: state.origin, + studioKey: state.key, + openfilmHome: STUDIO_HOME, + tell, + logger, + }); + desktop.localAgents = host.localAgents; + desktop.ipc = ipc; + desktop.ready = true; + desktop.error = null; + } catch (error) { + desktop.error = String(error?.message || error); + } finally { + desktop.loading = false; + } + return desktop.ready; + } + + /** + * 桌面上那个 OpenFilm app 在哪儿。 + * + * 面板里跑的是**网页版**(Studio 嵌在 iframe 里);桌面版是 Electron 的窗口,整体塞不进 + * 面板。所以面板上给一个按钮,把人送到他机器上真正装着的那个桌面版去 —— 那是带聊天栏、 + * 带本地 agent 的完整形态。 + * + * 候选在 `miniapps/openfilm/miniapp.config.json` 的 `desktopApp` 里,按顺序第一个存在的 + * 就是要打开的那个(`~` 开头当作家目录)。 + */ + async function desktopCandidates() { + const configured = Array.isArray(config.desktopApp) ? config.desktopApp : []; + const out = []; + for (const item of configured) { + if (typeof item === 'string' && item.trim()) out.push({ path: expandHome(item), args: null }); + else if (item && typeof item === 'object' && typeof item.path === 'string') { + out.push({ path: expandHome(item.path), args: Array.isArray(item.args) ? item.args.map(String) : null }); + } + } + if (out.length) return out; + /* 没配就自己找:先正式版,再仓库里的 Dev 版(版本目录新的在前)。 */ + out.push({ path: '/Applications/OpenFilm.app', args: null }); + out.push({ path: join(homedir(), 'Applications', 'OpenFilm.app'), args: null }); + const runtimeRoot = join(desktop.repoDir, 'apps', 'desktop', '.dev', 'runtime'); + try { + const dirs = (await readdir(runtimeRoot, { withFileTypes: true })) + .filter((d) => d.isDirectory()).map((d) => d.name).sort().reverse(); + for (const name of dirs) out.push({ path: join(runtimeRoot, name, 'OpenFilm Dev.app'), args: null }); + } catch { /* 没有仓库就没有 Dev 候选 */ } + return out; + } + + async function resolveDesktop() { + for (const candidate of await desktopCandidates()) { + const info = await stat(candidate.path).catch(() => null); + if (!info?.isDirectory()) continue; + /* Dev 版要靠参数找自己的开发资源(仓库的 apps/desktop);正式版不用带参数。 */ + const args = candidate.args + ?? (candidate.path.includes(`${sep}.dev${sep}runtime${sep}`) ? [join(desktop.repoDir, 'apps', 'desktop')] : []); + return { path: candidate.path, args }; + } + return null; + } + + /** 桌面上那个 app 是不是已经在跑了(ps 里找它的进程)。 */ + async function desktopRunning(app) { + const out = await new Promise((resolve) => { + const child = spawn('ps', ['-ax', '-o', 'command='], { stdio: ['ignore', 'pipe', 'ignore'] }); + const chunks = []; + child.stdout.on('data', (c) => chunks.push(c)); + child.on('error', () => resolve('')); + child.on('close', () => resolve(Buffer.concat(chunks).toString('utf8'))); + }); + const needle = basename(app); + return out.split('\n').some((line) => line.includes(needle) && !line.includes('ps -ax')); + } + + /** + * 把桌面版叫起来。 + * + * 已经跑着 → `open -a` 把它带到前面来。 + * 没跑着 → `open -n` 起一个(**要带参数**:Dev 版靠那个参数找它的开发资源, + * 不带就起不来;正式版不需要参数)。 + */ + async function openDesktop() { + const found = await resolveDesktop(); + if (!found) { + return { + ok: false, + error: 'no OpenFilm desktop app found', + candidates: (await desktopCandidates()).map((c) => c.path), + }; + } + const app = found.path; + const alreadyRunning = await desktopRunning(app); + const args = found.args; + const argv = alreadyRunning + ? ['-a', app] + : ['-n', app, ...(args.length ? ['--args', ...args] : [])]; + await new Promise((resolve) => { + const child = spawn('open', argv, { detached: true, stdio: 'ignore' }); + child.on('error', () => resolve()); + child.on('spawn', () => { child.unref(); resolve(); }); + }); + return { ok: true, app, alreadyRunning }; + } + + /** 问 Studio 要东西(带 key)。连不上返回 null。 */ + function studioGet(path) { + return new Promise((resolve) => { + const url = new URL(path, state.origin); + const req = httpRequest( + { + host: url.hostname, + port: url.port, + path: url.pathname + url.search, + method: 'GET', + timeout: PROBE_TIMEOUT_MS, + agent: false, + headers: { 'x-studio-key': state.key }, + }, + (response) => { + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + try { resolve(JSON.parse(text)); } catch { resolve(null); } + }); + }, + ); + req.on('timeout', () => { req.destroy(); resolve(null); }); + req.on('error', () => resolve(null)); + req.end(); + }); + } + + /** + * 让 Studio 自己收摊:`POST /api/quit`。 + * 不用杀进程 —— `openfilm open` 会把 Studio 甩成孤儿(它自己退出,Studio 继续活着), + * 孤儿进程的 pid 我们根本不知道。而 Studio 有自己的关闭口,干净。 + * 桌面版自己起的 Studio 会拒绝(423),那就留着 —— 那是它的。 + */ + function quitStudio() { + if (!state.origin || !state.key) return Promise.resolve(null); + return new Promise((resolve) => { + const url = new URL('/api/quit', state.origin); + const req = httpRequest( + { + host: url.hostname, + port: url.port, + path: url.pathname, + method: 'POST', + timeout: 5000, + agent: false, + headers: { 'x-studio-key': state.key, 'content-type': 'application/json' }, + }, + (response) => { + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => resolve({ status: response.statusCode, body: Buffer.concat(chunks).toString('utf8').slice(0, 120) })); + }, + ); + req.on('timeout', () => { req.destroy(); resolve(null); }); + req.on('error', () => resolve(null)); + req.end('{}'); + }); + } + + /** 把请求的 body 读完(JSON)。 */ + function readBody(request) { + return new Promise((resolve) => { + const chunks = []; + request.on('data', (c) => chunks.push(c)); + request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + request.on('error', () => resolve('')); + }); + } + + /** + * 桌面版的原生桥:页面调 `POST /ipc/`,这里按频道找 handler。 + * + * handler 是桌面 app 主进程里那些(纯 Node 函数),它们收的是 `(event, opts)`:event.sender + * 是页面的 webContents,我们给一个假的——它的 send() 把消息推给页面的 SSE。 + */ + async function handleIpc(request, url, response) { + /* 页面订阅的那些频道:一条 SSE 连着所有频道 */ + if (url.pathname === '/ipc/events') { + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-store', + connection: 'keep-alive', + }); + response.write(': connected\n\n'); + desktop.streams.add(response); + request.on('close', () => desktop.streams.delete(response)); + return; + } + + const channel = decodeURIComponent(url.pathname.slice('/ipc/'.length)); + if (request.method !== 'POST') { + response.writeHead(405, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'method_not_allowed' })); + return; + } + if (!(await ensureDesktopHost())) { + response.writeHead(503, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: desktop.error || 'OpenFilm is not up yet' })); + return; + } + const handler = desktop.ipc.get(channel); + if (!handler) { + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'no such channel', channel })); + return; + } + let payload = null; + try { payload = dec(JSON.parse((await readBody(request)) || 'null')); } catch { payload = null; } + try { + const result = await handler({ sender: desktop.sender }, payload); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify(enc(result) ?? null)); + } catch (error) { + response.writeHead(500, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: String(error?.message || error) })); + } + } + + /** + * Studio 的 /api/* 转到它那儿。 + * + * 桌面版模式里页面就在这个源上(它的 fetch 用的是绝对路径 /api/…),所以要同源转发。 + * key 我们替它带上;Origin 改成 Studio 自己的(它只认自己的来源)。 + */ + /* 两个源分工(这是预览能不能出画面的关键): + · 桌面版页面在 127.0.0.1 —— 影片页的 EDITOR 就指到这儿; + · 影片页在 localhost —— 跟桌面版**不同源**,bridge 才会启动 + (bridge.js 里「父页面与影片页同源就不启动」,同源反而把桥关了)。 + 两个名字都指向面板这一个服务,但浏览器眼里是两个源。 */ + const panelOrigin = `http://127.0.0.1:${listen.port}`; + + /** 影片源(Studio 的 pages 服务,run.json 或 /api/health 里的 filmOrigin)。 */ + let filmOriginCache = null; + async function filmOrigin() { + if (filmOriginCache) return filmOriginCache; + const run = await readRunFile(join(STUDIO_HOME, 'run.json')).catch(() => null); + if (run?.filmOrigin) { filmOriginCache = run.filmOrigin; return filmOriginCache; } + try { + const r = await fetch(state.origin + '/api/health', { headers: { 'x-studio-key': state.key } }); + const body = await r.json(); + if (body?.filmOrigin) { filmOriginCache = body.filmOrigin; return filmOriginCache; } + } catch { /* 下面按没有处理 */ } + return null; + } + + /** 一个响应是不是文本(要改写里面的源),以及怎么改写。 */ + const isText = (type) => /json|html|javascript|text\//i.test(String(type || '')); + + async function forwardToStudio(request, url, response) { + if (!state.origin || !state.key) { + response.writeHead(503, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'OpenFilm is not up yet' })); + return; + } + const chunks = []; + request.on('data', (c) => chunks.push(c)); + request.on('end', async () => { + const hasBody = request.method !== 'GET' && request.method !== 'HEAD'; + const headers = { ...request.headers, 'x-studio-key': state.key, origin: state.origin }; + delete headers.host; + delete headers['content-length']; + try { + const r = await fetch(state.origin + url.pathname + url.search, { + method: request.method, + headers, + ...(hasBody ? { body: Buffer.concat(chunks) } : {}), + }); + const out = {}; + r.headers.forEach((value, key) => { + if (key !== 'content-encoding' && key !== 'transfer-encoding' && key !== 'content-length') out[key] = value; + }); + let body = Buffer.from(await r.arrayBuffer()); + /* 影片源(4824)也走面板这个源:Preview 的 folder 变成面板地址,影片页才跟面板同源。 */ + const film = await filmOrigin(); + if (film && isText(out['content-type'])) { + body = Buffer.from(body.toString('utf8').replaceAll(film, panelOrigin), 'utf8'); + } + response.writeHead(r.status, out); + response.end(body); + } catch (error) { + response.writeHead(502, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: String(error?.message || error) })); + } + }); + } + + /** + * 影片页与它的素材:转到 Studio 的 pages 服务(filmOrigin,4824)。 + * + * 关键在 `EDITOR`:影片页只接受这个源发来的消息(bridge.js),而它默认是 4823。 + * 桌面版页面在面板这个源上,所以注入的 EDITOR 必须改写成面板源 —— 不改的话, + * 父子永远握不上手,预览就一直停在「正在绘制第一帧」。 + */ + async function forwardToFilm(request, url, response) { + const film = await filmOrigin(); + if (!film || !state.key || !state.origin) { + response.writeHead(503, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'the film origin is not up yet' })); + return; + } + const target = new URL(film); + const headers = { ...request.headers, host: target.host }; + delete headers['content-length']; + try { + const r = await fetch(film + url.pathname + url.search, { method: request.method, headers }); + const out = {}; + r.headers.forEach((value, key) => { + if (key !== 'content-encoding' && key !== 'transfer-encoding' && key !== 'content-length') out[key] = value; + }); + let body = Buffer.from(await r.arrayBuffer()); + if (isText(out['content-type'])) { + let text = body.toString('utf8').replaceAll(state.origin, panelOrigin); + /* bridge.js 原本「父页面与影片页同源就不启动」:那是为了不当编辑器的同域页面。 + 这里父页面就是编辑器(桌面版),只是恰好与影片页同源 —— 不能因此把桥关了。 + 只改我伺服出去的这份副本,不动上游。 */ + text = text.replace( + 'if (parent !== window && parent.location.origin === location.origin) return;', + '/* MiniApp: the editor may share this origin with the film page */', + ); + body = Buffer.from(text, 'utf8'); + } + if (out['access-control-allow-origin']) out['access-control-allow-origin'] = panelOrigin; + /* 临时排查:影片页与素材的应答状态(不记任何凭据) */ + response.writeHead(r.status, out); + response.end(body); + } catch (error) { + response.writeHead(502, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: String(error?.message || error) })); + } + } + + /** 桌面版的那一页(编辑器 + 聊天栏):它自己的 index.html,前面插上网页版的原生桥。 */ + async function desktopPage() { + let html = await readFile(join(desktop.shellDist, 'index.html'), 'utf8'); + html = html.replace( + '\n