From 7527d8667668b5f0681234beffc5a4455f7002c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=80=E7=88=B1=E5=8F=8C=E7=9A=AE=E5=A5=B6?= <316808445+cristinamadureira353-svg@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:22:39 +0800 Subject: [PATCH] Add OpenFilm MiniApp Run OpenFilm inside MiniMax Code with the desktop interface: the Studio editor plus the desktop chat column and local agents, by serving the desktop page and bridging window.openfilmNative over HTTP, reusing the desktop app's own Node handlers (chat, local agents, skills, roster). - desktopMode with web-Studio fallback when the OpenFilm clone/shell build is unavailable - project switcher, desktop-app launcher, concurrent-boot lock, dispose that closes agents, SSE streams and only our own Studio - state under context.dataDir; no secrets in pages or logs - README (en/zh-CN), LICENSE, root README rows Validated: npm run check (this package OK, 0 errors 0 warnings); 32-check acceptance covering boot, tree, desktop IPC, roster, SSE, web fallback, missing shell, concurrent boot and dispose. --- README.md | 1 + README.zh-CN.md | 1 + .../openfilm/.gitignore | 11 + .../openfilm/.minimax-plugin/plugin.json | 21 + .../cristinamadureira353-svg/openfilm/LICENSE | 21 + .../openfilm/NOTICE.md | 20 + .../openfilm/README.md | 98 ++ .../openfilm/README.zh-CN.md | 98 ++ .../openfilm/icon.png | Bin 0 -> 1574 bytes .../openfilm/miniapp.config.json | 10 + .../openfilm/miniapp/client/bridge-web.js | 198 +++ .../openfilm/miniapp/client/index.html | 143 +++ .../openfilm/miniapp/miniapp.json | 20 + .../openfilm/miniapp/node/desktop-host.mjs | 299 +++++ .../openfilm/miniapp/node/server.mjs | 1080 +++++++++++++++++ .../openfilm/package.json | 6 + .../openfilm/skills/openfilm/SKILL.md | 64 + .../openfilm/test/accept-miniapp.mjs | 247 ++++ 18 files changed, 2338 insertions(+) create mode 100644 plugins/cristinamadureira353-svg/openfilm/.gitignore create mode 100644 plugins/cristinamadureira353-svg/openfilm/.minimax-plugin/plugin.json create mode 100644 plugins/cristinamadureira353-svg/openfilm/LICENSE create mode 100644 plugins/cristinamadureira353-svg/openfilm/NOTICE.md create mode 100644 plugins/cristinamadureira353-svg/openfilm/README.md create mode 100644 plugins/cristinamadureira353-svg/openfilm/README.zh-CN.md create mode 100644 plugins/cristinamadureira353-svg/openfilm/icon.png create mode 100644 plugins/cristinamadureira353-svg/openfilm/miniapp.config.json create mode 100644 plugins/cristinamadureira353-svg/openfilm/miniapp/client/bridge-web.js create mode 100644 plugins/cristinamadureira353-svg/openfilm/miniapp/client/index.html create mode 100644 plugins/cristinamadureira353-svg/openfilm/miniapp/miniapp.json create mode 100644 plugins/cristinamadureira353-svg/openfilm/miniapp/node/desktop-host.mjs create mode 100644 plugins/cristinamadureira353-svg/openfilm/miniapp/node/server.mjs create mode 100644 plugins/cristinamadureira353-svg/openfilm/package.json create mode 100644 plugins/cristinamadureira353-svg/openfilm/skills/openfilm/SKILL.md create mode 100644 plugins/cristinamadureira353-svg/openfilm/test/accept-miniapp.mjs 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 0000000000000000000000000000000000000000..299a26cbf5c1427dffa9cd575cf721307d570765 GIT binary patch literal 1574 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrV7=|>;uumf=gr;N9Fb5F_7BDH zN*t40jx3m@A#jD`h{82~A;W#9hIKB=6V;QM-fp_!Iw3Du_^7z>7spdwD|5UT=(0U- z4vcs42{>&0A!&-l#UFng7Vv9zUfN`Qd~;|s@7+uz9O)JMQ;7Q z+VlNc*1f)P;(=w;neIK@5%Dq?bm#T^{r{9*@ia!gnj^~T2}4Q1*Th_Vu`>}GpIDCO z#4fN@Vt4rd)BClU(Q~fW#@QT3`o97jTK?xMOo;EM)AkEGZ>^la!>4}Tg|#orV!ID- z4G8cmi29Kul*I@Nov(+hUWIHbj*XAkN|#&6e?ev6^ZSxvcK?~yFA_-*H<;a0#(2g^ zzl72KdH~l2CW&?ibaY*)m{GcGsq4HCVQW8`+3MMAA9>%$$6x1gcEXjIh6=|UTHH*2 zPm^w)+0I})`*&FN5&Nu1Y+r6yc*L8(Sa9m$yrfKZ#~fa1&NHPdC8Y+RdyG?WF};29 zt~O0_E62oyX%mAzpJu)CiI9%EV`HT-k0aLM2a{@p^XrK&uP*%0esO=1w)UU=+zCGMvNmy= z|6P>ouW5@p2v)su`cxCfV?$ymt*7pfdX*%ncOdCuPzej4`!;F|NW(R z>9S1?D}6X`#NYh7GVkf}bgjz@`%TThovQS5e3ma}8>P8_foSNz|MS*u5I(ouy{)ZH zDAVlt_qYF?TCQcTsM{ODK6ko9)iJH7$x{Rh??u)A%=k6?)3WE!yQWM!akgaN_W!DX z-#&jmHN3X`^}*WR`-q(SpO3zY`t01fRdCqjHPcc83_B1GbPU2zNIsfpf)(T$7XRi5-O#h75#2n`^6HJMF!qC&lGw+h(&G*h? zk*k_rMVov+YqaeSTOYYZzjSt0F6+COd#ca&eqZ;_rtH&`lb%gyI!#lfHFBb5N}kls z$lZDCNY^>py$dH>z2A9YX;J*2GNpMejVH=I&vYJ{xB2|{X>r$mYq=dibJoiERAfK) zQ9W)K_T5@<--_8kB%7Wkzk0OuD!*pi4Mquhsb5FS-xaZWR zZ|9tq^y-Rf{wEFBD;u8QSW~|%f9dnxxlsofN1ZNZ>HPit!>su-YkBth3z|N4bh|s- zYxS08(KkMRy%ipDKxc+#k7En7+RrklI~Sdmo1bg6`PiN0n#CTy2vIV@izrM+Ju?Ht m|NpbhtN4MXJsYT)XJ&Z(ZtAoneFio_5e83JKbLh*2~7YK=FPVN literal 0 HcmV?d00001 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