From 1b0cc455f92fc8075cceacdc309547d11087da3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E6=A2=B5=E5=B0=BC?= Date: Sun, 8 Nov 2026 09:16:02 +0800 Subject: [PATCH] Update OpenFilm MiniApp to OpenFilm 0.3.0 - Pin the npm fallback to openfilm@0.3.0 and start Studio by calling upstream's startStudio from the local clone instead of `openfilm open` (that path reuses a machine-wide Studio and can launch an incomplete Electron app). - Let the OS pick the Studio port (port: 0) and read the real origin and film origin back from the run.json it writes; a fixed port collided. - Spawn the worker with ELECTRON_RUN_AS_NODE: the panel runs inside Electron, so process.execPath is the helper binary, not node. - Merge the panel's own project list with the user's instead of overwriting it. - Register the 0.3.0 IPC additions (chat:undo with turnId, agents:refresh) and pass upstream's trash into the chat handler. - Restrict local agent starts to project folders. - Fail visibly when the spawned Studio dies; kill only the process we started. - Resolve the npm fallback asynchronously; update README/NOTICE/SKILL for 0.3.0. - Acceptance script: 49 checks. --- .../openfilm/README.md | 28 +- .../openfilm/README.zh-CN.md | 28 +- .../openfilm/miniapp.config.json | 2 +- .../openfilm/miniapp/node/desktop-host.mjs | 6 +- .../openfilm/miniapp/node/server.mjs | 333 +++++++++++++----- .../openfilm/skills/openfilm/SKILL.md | 4 +- .../openfilm/test/accept-miniapp.mjs | 47 ++- 7 files changed, 326 insertions(+), 122 deletions(-) diff --git a/plugins/cristinamadureira353-svg/openfilm/README.md b/plugins/cristinamadureira353-svg/openfilm/README.md index 2a5dae37..82d0b2ac 100644 --- a/plugins/cristinamadureira353-svg/openfilm/README.md +++ b/plugins/cristinamadureira353-svg/openfilm/README.md @@ -21,7 +21,7 @@ The panel is the OpenFilm desktop page itself. The native bridge the desktop app ## 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`). +- Node.js 22+ and `npx` (only needed as a fallback when there is no local OpenFilm clone). - **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 @@ -44,19 +44,20 @@ Edit `miniapp.config.json` in the plugin directory. Put machine-specific overrid | Key | Default | Meaning | |---|---|---| -| `openfilmBin` / `openfilmArgs` | `npx -y openfilm@0.2.5` (pinned) | Which OpenFilm to start (first run downloads it from npm). | | `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 (fixed) | The app the `POST /miniapp/open-desktop` endpoint launches: repo dev runtimes first, then `/Applications/OpenFilm.app`. Not configurable. | -| `studioHome` / `studioPort` | `context.dataDir/openfilm-studio` / `4823` | The panel's own Studio home/port. `studioHome` may be overridden for an existing state directory (documented exception); `studioPort` for isolated tests. | +| `studioHome` | `context.dataDir/openfilm-studio` | The panel's own Studio home. May be overridden for an existing state directory (documented exception). There is no port setting: Studio asks the OS for a free one (`port: 0`) and its real origin and film origin are read back from the `run.json` it writes there. | + +There is no configurable executable or argument list either: the OpenFilm version is pinned in code (`PINNED_OPENFILM_VERSION` in `miniapp/node/server.mjs`). ## Data & access **Processes started by this package** -- `npx -y openfilm@0.2.5 open` — starts the Studio (`openfilmBin`/`openfilmArgs` are pinned to this; the first run downloads OpenFilm from `registry.npmjs.org`). +- The Studio is started by calling upstream's `startStudio` directly (`node --input-type=module -e 'import { startStudio } ...'`) from the local clone's `packages/openfilm/studio/server/server.mjs`, with `port: 0` (the OS picks a free port, and the real origin and film origin are read back from the `run.json` it writes), `advertise: true` and no browser opener. It does **not** run `openfilm open` (that path reuses a machine-wide Studio and can launch other apps). When no clone is present, the pinned `openfilm@0.3.0` from npm is used instead (first run downloads it from `registry.npmjs.org`). - `open -n ` / `open -a ` (macOS) — only for the fixed app locations in `desktopCandidates()` (dev runtimes under `/apps/desktop/.dev/runtime`, then `/Applications/OpenFilm.app` and `~/Applications/OpenFilm.app`; the dev candidates depend on `openfilmRepo` / `OPENFILM_REPO`). - `open -R ` — reveals a project path in Finder (restricted to project folders). - `ps -ax -o command=` — to check whether that app is already running. @@ -64,7 +65,7 @@ Edit `miniapp.config.json` in the plugin directory. Put machine-specific overrid **Network** -- `registry.npmjs.org` (first `npx` run). +- `registry.npmjs.org` (only for the npm fallback, when no local clone exists). - Loopback only for the panel itself and Studio (`127.0.0.1`/`localhost`). Agents you invoke use their own accounts/network as usual. **Files read** @@ -72,6 +73,7 @@ Edit `miniapp.config.json` in the plugin directory. Put machine-specific overrid - `~/.openfilm/studio.json` (or `~/.openfilm-dev/studio.json` when `flavor` is `dev`): which projects exist (read-only). - Each listed project's `.film/id` (read-only), to address the project. - `/apps/desktop/src/*.mjs` — the desktop handlers, imported and run (the documented desktop-mode requirement). +- `/packages/openfilm/studio/server/trash.mjs` — upstream's `moveToTrash`, used by the chat's "undo a turn" (it puts a file the turn added into the system trash). - `/apps/desktop/.dev/runtime/*` — to find a dev app to launch. - `/apps/desktop/shell/dist/*` — the desktop page served. - Agents' own config/session records, via the desktop handlers in `/apps/desktop/src`: @@ -86,6 +88,7 @@ Edit `miniapp.config.json` in the plugin directory. Put machine-specific overrid **Files written** - Only under `context.dataDir` (`openfilm-studio/`): Studio home, preferences, caches, logs, the BYOK master key and sealed BYOK keys. +- `studio.json` **in that same Studio home** is written from the project list read in your own OpenFilm home (`recent` and `folders`). The bundled Studio reads its own `studio.json`, and the two have to agree or the editor answers "no such project". Your own `~/.openfilm/studio.json` is only read, never written. - Project data is written through OpenFilm's own API/handlers (chat records under `.film/`, agent sessions in project folders), exactly as the desktop app does — not by the panel itself. - A legacy `studioHome` override (`miniapp.config.local.json`) may point the Studio home elsewhere (this machine's original `~/.minimax/openfilm-studio`). It is an explicit, documented exception for an existing state directory, not a general writable path. @@ -94,9 +97,9 @@ Edit `miniapp.config.json` in the plugin directory. Put machine-specific overrid - **Loopback authentication is done by the Host** (MiniMax Code 3.1.2+): every `node:http` server in the MiniApp process requires the per-run credential, including direct local connections and WebSocket upgrades. This package implements no token logic of its own. - **No secrets in pages, client JavaScript, logs, or error responses.** `/miniapp/studio` no longer returns the Studio key, and no iframe URL contains `/open?key=`; both web and desktop editors are served same-origin with the key attached only on the server side. -- **Bounded proxies.** `/api/*` allows only the route prefixes the editor uses (`/api/health`, `/api/projects`, `/api/files`, `/api/doctor`, `/api/library`, `/api/saved`, `/api/skills`, `/api/prefs`, `/api/examples`, `/api/fonts`, `/api/page-links`, `/api/exports`, `/api/look`) and forwards only `accept`, `content-type`, `range`. `/p/*` is read-only GET/HEAD. WebSocket upgrades are limited to `/api/events` and `/api/projects//events`, with only the handshake headers plus the Studio key. +- **Bounded proxies.** `/api/*` allows only the route prefixes and methods the editor uses (`/api/health`, `/api/version`, `/api/projects`, `/api/files`, `/api/page-links`, `/api/exports`, `/api/look`, `/api/doctor`, `/api/diagnostics`, `/api/library`, `/api/canvas`, `/api/canvas-templates`, `/api/canvas-characters`, `/api/kept`, `/api/local-models`, `/api/saved`, `/api/skills`, `/api/prefs`, `/api/examples`, `/api/fonts`, `/api/browse`, `/api/locations`, `/api/pick-folder`, `/api/pick-files`) and forwards only `accept`, `content-type`, `range`. `/api/get` (generates media, may cost) and `/api/providers` (your service keys) are not proxied. `/p/*` is read-only GET/HEAD. WebSocket upgrades are limited to `/api/events` and `/api/projects//events`, with only the handshake headers plus the Studio key. - **IPC allowlist with payload validation** (`IPC_RULES` in `server.mjs`): installer, sign-in and `automatic` flows are not registered at all (they return 403), and every other channel has a parameter check. `desktop:reveal` is limited to project folders. As in the desktop app, the page can also answer a local agent's permission prompts on your behalf (`desktop:local-agents:permission`) — that is how the chat's permission cards work, and it is your own UI. -- **Fixed process launches.** `open-desktop` only opens the auto-detected OpenFilm app bundles; `openfilmBin`/`openfilmArgs` are pinned to `npx -y openfilm@0.2.5` with a minimal environment (`PATH`, `HOME`, `LANG`, `TMPDIR`, `OPENFILM_*`). +- **Fixed process launches.** `open-desktop` only opens the auto-detected OpenFilm app bundles. Studio is started with upstream's `startStudio` (OS-assigned port, no browser) using a minimal environment (`PATH`, `HOME`, `LANG`, `TMPDIR`, `OPENFILM_*`); the npm fallback is pinned to `openfilm@0.3.0`. - **State lives in `context.dataDir`** (`openfilm-studio/`). 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). This is obfuscation for at-rest files, not keychain-grade protection — the master key sits in the same directory, and a legacy `raw:` (plaintext) value is also accepted for compatibility. @@ -107,16 +110,17 @@ Edit `miniapp.config.json` in the plugin directory. Put machine-specific overrid - Agent **installer, sign-in and `automatic` flows are not offered in the panel** (use the desktop app for those). - 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). -- The API proxy intentionally excludes admin routes (`/api/quit`, `/api/update`, `/api/open`, `/api/keys`); actions that need them are unavailable in the panel. +- The API proxy is limited to the routes and methods the editor actually uses, and excludes admin routes (`/api/quit`, `/api/update`, `/api/open`, `/api/desktop`). +- **`/api/get` and `/api/providers` are not proxied**: generating media with a service (voice, music, images, video) and connecting a service (its keys) are not available in the panel — use the desktop app for those. The canvas (⌘E), the assets library and the rest of the editor work in the panel. - 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. +- MiniMax Code desktop 3.1.2+ on macOS (Apple Silicon), Node 22, OpenFilm 0.3.0. +- Desktop page renders with the chat column and real conversation history. - `chat:load` returns real sessions; agent roster lists 11 agents; Codex ACP probe starts a session; Claude Code correctly reports not-installed. -- Acceptance script: 44 checks covering boot, tree, desktop IPC, roster, SSE, web fallback, missing-shell error, concurrent boot (one Studio only), dispose with open SSE streams, the review hardening assertions (no key to the page, route/IPC allowlists, payload validation, reveal restriction), and the preview path (film page proxied, editor origin rewritten, bridge allowed same-origin, media served). +- Acceptance script: 49 checks (the few project-specific ones are skipped, and said so, on a machine with no OpenFilm projects) covering boot, tree, desktop IPC, roster, SSE, web fallback, missing-shell error, concurrent boot (one Studio only), dispose with open SSE streams, the review hardening assertions (no key to the page, route/IPC allowlists, payload validation, reveal restriction), and the preview path (film page proxied, editor origin rewritten, bridge allowed same-origin, media served). Run the acceptance yourself: @@ -124,7 +128,7 @@ Run the acceptance yourself: 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. +It uses temporary homes and OS-assigned ports 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 diff --git a/plugins/cristinamadureira353-svg/openfilm/README.zh-CN.md b/plugins/cristinamadureira353-svg/openfilm/README.zh-CN.md index ff622787..762042ad 100644 --- a/plugins/cristinamadureira353-svg/openfilm/README.zh-CN.md +++ b/plugins/cristinamadureira353-svg/openfilm/README.zh-CN.md @@ -21,7 +21,7 @@ ## 前提 - macOS 上的 MiniMax Code **3.1.2 或更新**(Host 的 loopback 凭据守着面板的端口)。 -- Node.js 22+ 和 `npx`(面板用 `npx -y openfilm@0.2.5` 起 OpenFilm)。 +- Node.js 22+ 和 `npx`(只在没有本地 OpenFilm 克隆时作为回退才用到)。 - **只有桌面模式需要**:本地克隆 [openfilm/openfilm](https://github.com/openfilm/openfilm),并构建一次桌面版页面。`@openfilm/desktop` 没发布到 npm,所以这些模块只能从仓库来。 ```sh @@ -44,19 +44,20 @@ manifest 最后要在:`~/.minimax/plugins/openfilm/.minimax-plugin/plugin.json | 键 | 默认 | 含义 | |---|---|---| -| `openfilmBin` / `openfilmArgs` | `npx -y openfilm@0.2.5`(固定) | 起哪个 OpenFilm(第一次会从 npm 下载)。 | | `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:先仓库里的 Dev 版,再 `/Applications/OpenFilm.app`。不可配置。 | -| `studioHome` / `studioPort` | `context.dataDir/openfilm-studio` / `4823` | 面板自己那份 Studio 的 home 和端口。`studioHome` 可为已有状态目录覆盖(文档写明的例外);`studioPort` 用于隔离测试。 | +| `studioHome` | `context.dataDir/openfilm-studio` | 面板自己那份 Studio 的 home。可为已有状态目录覆盖(文档写明的例外)。**没有端口配置**:Studio 向系统要一个空闲端口(`port: 0`),真实端口和影片端口从它写在那里的 `run.json` 读回。 | + +也没有可配置的可执行文件和参数:OpenFilm 的版本钉在代码里(`miniapp/node/server.mjs` 的 `PINNED_OPENFILM_VERSION`)。 ## 数据与访问 **这个包会启动的进程** -- `npx -y openfilm@0.2.5 open` —— 起 Studio(`openfilmBin`/`openfilmArgs` 已固定为这个;第一次跑会从 `registry.npmjs.org` 下载 OpenFilm)。 +- 面板直接调上游的 `startStudio`(`node --input-type=module -e 'import { startStudio } ...'`),入口用本地克隆的 `packages/openfilm/studio/server/server.mjs`,用 `port: 0` 让系统挑空闲端口(真实端口和影片端口从它写的 `run.json` 读回)、`advertise: true`、不开浏览器。**不跑 `openfilm open`**(那条路会去复用机器上已有的 Studio,可能拉起别的 app)。没有克隆时才用 npm 上钉住的 `openfilm@0.3.0`(第一次跑从 `registry.npmjs.org` 下载)。 - `open -n ` / `open -a `(macOS)—— 只开 `desktopCandidates()` 里那些固定位置(先 `/apps/desktop/.dev/runtime` 下的 Dev 版,再 `/Applications/OpenFilm.app`、`~/Applications/OpenFilm.app`;Dev 候选依赖 `openfilmRepo` / `OPENFILM_REPO`)。 - `open -R ` —— 在访达里显示项目路径(只限项目文件夹)。 - `ps -ax -o command=` —— 看那个 app 是不是已经在跑。 @@ -64,7 +65,7 @@ manifest 最后要在:`~/.minimax/plugins/openfilm/.minimax-plugin/plugin.json **网络** -- `registry.npmjs.org`(第一次 `npx`)。 +- `registry.npmjs.org`(仅 npm 回退时,也就是没有本地克隆时)。 - 面板和 Studio 之间只有本机回环(`127.0.0.1`/`localhost`)。你调用的 agent 用它们自己的账号和网络。 **会读的文件** @@ -72,6 +73,7 @@ manifest 最后要在:`~/.minimax/plugins/openfilm/.minimax-plugin/plugin.json - `~/.openfilm/studio.json`(`flavor` 为 `dev` 时是 `~/.openfilm-dev/studio.json`):有哪些项目(只读)。 - 每个列出的项目的 `.film/id`(只读),用来定位项目。 - `/apps/desktop/src/*.mjs` —— 桌面 handler,会被导入并运行(文档写明的桌面模式前提)。 +- `/packages/openfilm/studio/server/trash.mjs` —— 上游的 `moveToTrash`,聊天里「撤销一个回合」用它把新增的文件放进系统废纸篓。 - `/apps/desktop/.dev/runtime/*` —— 找要叫起的 Dev 版 app。 - `/apps/desktop/shell/dist/*` —— 伺服桌面版页面。 - agent 自己的配置/会话记录(通过 `/apps/desktop/src` 里的桌面 handler): @@ -86,6 +88,7 @@ manifest 最后要在:`~/.minimax/plugins/openfilm/.minimax-plugin/plugin.json **会写的文件** - 只在 `context.dataDir`(`openfilm-studio/`)里:Studio home、偏好、缓存、日志、BYOK 主密钥和封存后的 BYOK 密钥。 +- **同一份 Studio home 里的 `studio.json`** 是用「你自己 OpenFilm home 里读到的项目列表」(`recent` 和 `folders`)写成的。面板里那份 Studio 读的是它自己的 `studio.json`,两边不一致编辑器就会答「no such project」。你自己的 `~/.openfilm/studio.json` 只读、不写。 - 项目数据通过 OpenFilm 自己的 API/handler 写入(`.film/` 下的聊天记录、项目目录里的 agent 会话),跟桌面 app 一样,不是面板自己写的。 - 旧的 `studioHome` 覆盖(`miniapp.config.local.json`)可以把 Studio home 指到别处(这台机器原来的 `~/.minimax/openfilm-studio`)。那是对已有状态目录的明确例外,不是一个任意可写路径。 @@ -94,9 +97,9 @@ manifest 最后要在:`~/.minimax/plugins/openfilm/.minimax-plugin/plugin.json - **loopback 鉴权由 Host 做**(MiniMax Code 3.1.2+):MiniApp 进程里的每个 `node:http` 服务都要每次运行一个凭据,包括本机直连和 WebSocket。这个包自己不造 token。 - **页面、客户端 JS、日志、错误响应里都不放密钥**。`/miniapp/studio` 不再返回 Studio 的 key,iframe 地址里不再有 `/open?key=`;网页版和桌面版编辑器都同源伺服,key 只在服务端带上。 -- **代理有边界**。`/api/*` 只放编辑器用到的前缀(`/api/health`、`/api/projects`、`/api/files`、`/api/doctor`、`/api/library`、`/api/saved`、`/api/skills`、`/api/prefs`、`/api/examples`、`/api/fonts`、`/api/page-links`、`/api/exports`、`/api/look`),请求头只放 `accept`、`content-type`、`range`。`/p/*` 只读 GET/HEAD。WebSocket 只放 `/api/events` 和 `/api/projects//events`,只带握手头和 Studio key。 +- **代理有边界**。`/api/*` 只放编辑器用到的前缀与方法(`/api/health`、`/api/version`、`/api/projects`、`/api/files`、`/api/page-links`、`/api/exports`、`/api/look`、`/api/doctor`、`/api/diagnostics`、`/api/library`、`/api/canvas`、`/api/canvas-templates`、`/api/canvas-characters`、`/api/kept`、`/api/local-models`、`/api/saved`、`/api/skills`、`/api/prefs`、`/api/examples`、`/api/fonts`、`/api/browse`、`/api/locations`、`/api/pick-folder`、`/api/pick-files`),请求头只放 `accept`、`content-type`、`range`。`/api/get`(生成媒体,可能花钱)和 `/api/providers`(你的服务密钥)不代理。`/p/*` 只读 GET/HEAD。WebSocket 只放 `/api/events` 和 `/api/projects//events`,只带握手头和 Studio key。 - **IPC 白名单 + 参数校验**(`server.mjs` 的 `IPC_RULES`):安装、登录、`automatic` 一律不注册(403),其它频道逐个查参数。`desktop:reveal` 只限项目文件夹。和桌面版一样,页面也可以替你回答本地 agent 的权限提问(`desktop:local-agents:permission`)—— 聊天里的权限卡片就是这么工作的,而且这是你自己的界面。 -- **进程启动固定**。`open-desktop` 只开自动发现的 OpenFilm app;`openfilmBin`/`openfilmArgs` 固定为 `npx -y openfilm@0.2.5`,环境变量只给 `PATH`、`HOME`、`LANG`、`TMPDIR`、`OPENFILM_*`。 +- **进程启动固定**。`open-desktop` 只开自动发现的 OpenFilm app。Studio 用上游的 `startStudio`(端口由系统分配、不开浏览器)以最小环境变量启动(`PATH`、`HOME`、`LANG`、`TMPDIR`、`OPENFILM_*`);npm 回退固定为 `openfilm@0.3.0`。 - **状态放在 `context.dataDir`**(`openfilm-studio/`),插件目录里不写任何运行时数据。 - 在面板里保存的自有密钥(BYOK)用本机 `0600` 主密钥做 AES-256-GCM 封存(桌面 app 用的是系统钥匙串;两边不共享)。这只是对落盘文件的混淆,不是钥匙串级保护 —— 主密钥就在同一目录里,而且也兼容旧的 `raw:`(明文)值。 @@ -107,16 +110,17 @@ manifest 最后要在:`~/.minimax/plugins/openfilm/.minimax-plugin/plugin.json - 面板里**不提供 agent 的安装、登录、`automatic` 流程**(这些请去桌面版做)。 - 原生文件/目录选择框和截屏返回 `null`(浏览器里没有原生对话框)。 - 桌面模式需要 OpenFilm 仓库 + 构建(见前提)。 -- API 代理刻意排除了管理类路由(`/api/quit`、`/api/update`、`/api/open`、`/api/keys`),需要它们的操作在面板里不可用。 +- API 代理只放编辑器真正用到的路由与方法,并排除了管理类路由(`/api/quit`、`/api/update`、`/api/open`、`/api/desktop`)。 +- **`/api/get` 与 `/api/providers` 不代理**:用服务生成媒体(配音、音乐、图片、视频)和连接服务(填密钥)在面板里不可用,请用桌面版。画布(⌘E)、素材库和编辑器其余部分在面板里都正常。 - 只在 macOS 测过;Windows/Linux 未测(有些地方用了 `open`、`pbcopy`、`ps`)。 ## 测试环境 -- macOS(Apple Silicon)上的 MiniMax Code 桌面版 3.1.2+,Node 22,OpenFilm 0.2.5。 -- 桌面版页面带聊天栏和真实会话历史渲染正常,切项目正常。 +- macOS(Apple Silicon)上的 MiniMax Code 桌面版 3.1.2+,Node 22,OpenFilm 0.3.0。 +- 桌面版页面带聊天栏和真实会话历史渲染正常。 - `chat:load` 返回真实会话;agent 名单 11 个;Codex ACP 探测能起会话;Claude Code 如实报未安装。 -- 验收脚本 44 项:启动、项目列表、桌面 IPC、名单、SSE、网页降级、缺构建报错、并发只起一个 Studio、SSE 开着时 dispose 不挂、评审要求的安全断言(页面无 key、路由与 IPC 白名单、参数校验、reveal 限制),以及预览链路(影片页代理、EDITOR 改写、同源放开、素材可读)。 +- 验收脚本 49 项(没有 OpenFilm 项目的机器上,少数与项目相关的会明确跳过):启动、项目列表、桌面 IPC、名单、SSE、网页降级、缺构建报错、并发只起一个 Studio、SSE 开着时 dispose 不挂、评审要求的安全断言(页面无 key、路由与 IPC 白名单、参数校验、reveal 限制),以及预览链路(影片页代理、EDITOR 改写、同源放开、素材可读)。 自己跑一遍验收: @@ -124,7 +128,7 @@ manifest 最后要在:`~/.minimax/plugins/openfilm/.minimax-plugin/plugin.json node test/accept-miniapp.mjs # 不想跑 Codex ACP 探测就 SKIP_PROBE=1 ``` -全部实例用临时 home 和端口(4900–4903),跑完自己收掉;不碰你正在用的面板,也不写你的项目。桌面模式的检查需要 OpenFilm 仓库 + 构建(见前提)。装好后也开一次面板,看看设置 → 智能体。 +全部实例用临时 home 和系统分配的空闲端口,跑完自己收掉;不碰你正在用的面板,也不写你的项目。桌面模式的检查需要 OpenFilm 仓库 + 构建(见前提)。装好后也开一次面板,看看设置 → 智能体。 ## 许可 diff --git a/plugins/cristinamadureira353-svg/openfilm/miniapp.config.json b/plugins/cristinamadureira353-svg/openfilm/miniapp.config.json index 6324009b..84439f64 100644 --- a/plugins/cristinamadureira353-svg/openfilm/miniapp.config.json +++ b/plugins/cristinamadureira353-svg/openfilm/miniapp.config.json @@ -1,5 +1,5 @@ { "flavor": "release", "desktopMode": true, - "_notes": "Pinned: OpenFilm is started as `npx -y openfilm@0.2.5` (no configurable executables or args). flavor: which project list to show — release=~/.openfilm + ~/Movies/OpenFilm/Projects (default), dev=~/.openfilm-dev + ~/Movies/OpenFilm Dev/Projects. desktopMode: true = desktop interface (editor + chat + local agents), false = plain web Studio. openfilmRepo: local openfilm/openfilm clone needed for desktop mode (the desktop handlers are not published to npm), default ~/Developer/openfilm, or OPENFILM_REPO env; run `pnpm --filter @openfilm/desktop build:shell` there once. shellDist: built desktop page (default /apps/desktop/shell/dist). desktopApp: fixed auto-detection only (repo dev runtimes first, then /Applications/OpenFilm.app) — not configurable. studioHome/studioPort: overrides for isolated tests or legacy state directories (documented in ## Data & access). Machine-specific overrides belong in miniapp.config.local.json (gitignored)." + "_notes": "Pinned: the OpenFilm version is pinned in code (`PINNED_OPENFILM_VERSION` in miniapp/node/server.mjs, currently 0.3.0); the panel starts it by calling upstream's `startStudio` with `port: 0`, so the OS picks a free port — there is no configurable executable, argument list or port. flavor: which project list to show — release=~/.openfilm + ~/Movies/OpenFilm/Projects (default), dev=~/.openfilm-dev + ~/Movies/OpenFilm Dev/Projects. desktopMode: true = desktop interface (editor + chat + local agents), false = plain web Studio. openfilmRepo: local openfilm/openfilm clone needed for desktop mode (the desktop handlers are not published to npm), default ~/Developer/openfilm, or OPENFILM_REPO env; run `pnpm --filter @openfilm/desktop build:shell` there once. shellDist: built desktop page (default /apps/desktop/shell/dist). desktopApp: fixed auto-detection only (repo dev runtimes first, then /Applications/OpenFilm.app) — not configurable. studioHome: the panel's own Studio home; an override for an existing state directory (documented in ## Data & access). Machine-specific overrides belong in miniapp.config.local.json (gitignored)." } diff --git a/plugins/cristinamadureira353-svg/openfilm/miniapp/node/desktop-host.mjs b/plugins/cristinamadureira353-svg/openfilm/miniapp/node/desktop-host.mjs index 380a2521..496a5f1a 100644 --- a/plugins/cristinamadureira353-svg/openfilm/miniapp/node/desktop-host.mjs +++ b/plugins/cristinamadureira353-svg/openfilm/miniapp/node/desktop-host.mjs @@ -87,6 +87,8 @@ export async function registerDesktopIpc(ipcMain, options) { const load = async (file) => import(pathToFileURL(join(src, file)).href); const { registerChatIpc, readPreferences } = await load('chat-ipc.mjs'); + /* 上游的废纸篓(0.3.0 的「撤销一个聊天回合」要用它把新增的文件放回废纸篓)。 */ + const { moveToTrash } = await import(pathToFileURL(join(repoDir, 'packages', 'openfilm', 'studio', 'server', 'trash.mjs')).href); const { registerLocalAgentIpc } = await load('local-agents-ipc.mjs'); const { registerSkillsIpc } = await load('skills-ipc.mjs'); @@ -119,6 +121,7 @@ export async function registerDesktopIpc(ipcMain, options) { projects, dataDir, reveal, + trash: (path) => moveToTrash(path), tell, log, }); @@ -152,7 +155,7 @@ export async function registerDesktopIpc(ipcMain, options) { /* 下面这些是 app 自己的(窗口、标签、更新、登录)——网页里没有,就照实说没有。 */ ipcMain.handle('desktop:hello', () => ({ flavor: 'dev', - version: options.openfilmVersion || '0.2.5', + version: options.openfilmVersion || '0.3.0', ffmpeg: null, update: null, chatOpen: true, @@ -225,6 +228,7 @@ export async function registerDesktopIpc(ipcMain, options) { }); ipcMain.handle('desktop:agents:list', () => { void roster.refresh(); return roster.list(); }); + ipcMain.handle('desktop:agents:refresh', () => roster.recheck()); 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 })); diff --git a/plugins/cristinamadureira353-svg/openfilm/miniapp/node/server.mjs b/plugins/cristinamadureira353-svg/openfilm/miniapp/node/server.mjs index ecab460f..0983c354 100644 --- a/plugins/cristinamadureira353-svg/openfilm/miniapp/node/server.mjs +++ b/plugins/cristinamadureira353-svg/openfilm/miniapp/node/server.mjs @@ -30,31 +30,30 @@ * 同一个规矩:取最长的那个前缀)。官方版把子文件夹也列成平级的项目,这里把它缩在父项目下面。 * 项目 id 在各项目的 `.film/id` 里(Studio 算的,同一个文件夹永远是同一个 id)。 */ -import { readFile, stat, mkdir, open, readdir } from 'node:fs/promises'; +import { readFile, stat, mkdir, open, readdir, writeFile } 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 { fileURLToPath, pathToFileURL } 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(不抢用户的),自己的端口意味着它不跟谁撞。 + * 这个 Mini App 自己那份 Studio 的 home:**跟用户那份分开**,它写自己的 run.json,不抢用户的。 + * + * 端口**不写死**:交给系统分配(`port: 0`)。写死一个端口会在两处撞:机器上别的 OpenFilm 正占着它, + * 或者同一台机器上已经有一份面板在跑 —— 两种都是 `EADDRINUSE`,起不来。 + * 起好之后真实端口在它自己写的 run.json 里(origin / filmOrigin),下面一律按 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 = { @@ -75,13 +74,96 @@ 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']; +/** 没有本地克隆时跑 npm 上的正式发布版(= GitHub 官方仓库的发布版),不用 PATH 上那个可能更旧的。 */ +const PINNED_OPENFILM_VERSION = '0.3.0'; /** 桌面版模式:把桌面 app 的界面(编辑器 + 聊天栏)跑在面板里。 */ const DEFAULT_OPENFILM_REPO = join(homedir(), 'Developer/openfilm'); +/** + * 怎么起 Studio。 + * + * 不用 `openfilm open`:那条路径会先去「复用这台机器上已有的 Studio」,可能碰到一个 + * 用 Electron 起的老实例(甚至拉起一个 helper 不全的 app),还可能检查更新、开浏览器。 + * 我们直接调 `startStudio`(桌面 app 也是这么做的),advertise 让它写 run.json, + * openBrowser 置空,完全由面板自己管。 + * + * 有本地克隆就用克隆自己的 server.mjs(版本一定跟克隆一致);没有克隆才用 npx 的 + * 固定版本(把它的 studio/server/server.mjs 解析出来)。 + */ +async function resolveOpenFilmCli(repoDir) { + const serverMjs = join(repoDir, 'packages', 'openfilm', 'studio', 'server', 'server.mjs'); + const pkg = join(repoDir, 'packages', 'openfilm', 'package.json'); + const version = await readFile(pkg, 'utf8').then((t) => JSON.parse(t)?.version).catch(() => null); + if (version && await stat(serverMjs).catch(() => null)) { + return { serverMjs, version: String(version), source: 'clone' }; + } + return { serverMjs: null, version: PINNED_OPENFILM_VERSION, source: 'npx' }; +} + +/** npx 回退:把钉住版本里的 studio/server/server.mjs 解析出来(走 npm 缓存,不复制源码进包)。 */ +async function resolveNpxServer() { + const { execFile } = await import('node:child_process'); + /* npx 会把包装进自己的缓存;`npx -y -p openfilm@ -c 'node -p ...'` 把入口解析出来。 + 必须异步:同步等会让面板在这个子进程跑完之前一个字也应答不了(最坏 120 秒)。 */ + const spec = `openfilm@${PINNED_OPENFILM_VERSION}`; + const found = await new Promise((resolve, reject) => { + execFile('npx', ['-y', '-p', spec, '-c', 'node -p "require.resolve(\'openfilm/package.json\')"'], + { encoding: 'utf8', timeout: 120000, maxBuffer: 1 << 20 }, + (error, stdout, stderr) => { + if (error) reject(new Error(String(stderr || error.message).trim().slice(0, 200) || 'npx failed')); + else resolve(String(stdout).trim()); + }); + }); + if (!found) throw new Error('npx printed nothing'); + return pathToFileURL(join(dirname(found), 'studio', 'server', 'server.mjs')).href; +} + +/** + * 面板自己那份 Studio 读的是它 home 里的 studio.json;用户看到的是用户 home 里那份。 + * 两边不一致,编辑器里 /api/projects/ 就会答「no such project」,预览也出不来。 + * + * 所以起 Studio 前把两边**合并**着写一份:先自己那份(面板里新建的项目不能丢), + * 再补上用户 home 里没有的(用户桌面 app 的项目),按 path 去重。 + * 只写面板自己 home 的那个文件;用户 home 的只读。 + */ +async function syncProjectList(userHome, studioHome) { + const read = async (file) => { + try { + const parsed = JSON.parse(await readFile(file, 'utf8')); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } + }; + const own = await read(join(studioHome, 'studio.json')); + const user = await read(join(userHome, 'studio.json')); + /* 两份都没有:让 Studio 自己从库目录扫,别在这里造一个空的把它盖了。 */ + if (!own && !user) return; + const merged = { ...(own ?? {}) }; + for (const [key, value] of Object.entries(user ?? {})) { + if (!(key in merged)) merged[key] = value; + } + const seenPath = new Set(); + merged.recent = [...(own?.recent ?? []), ...(user?.recent ?? [])].filter((row) => { + const key = row?.path ?? row?.id; + if (!key || seenPath.has(key)) return false; + seenPath.add(key); + return true; + }); + const seenFolder = new Set(); + merged.folders = [...(own?.folders ?? []), ...(user?.folders ?? [])].filter((folder) => { + const key = folder?.id ?? folder?.path; + if (!key || seenFolder.has(key)) return false; + seenFolder.add(key); + return true; + }); + try { + await mkdir(studioHome, { recursive: true }); + await writeFile(join(studioHome, 'studio.json'), JSON.stringify(merged, null, 2) + '\n'); + } catch { /* 写不了就算了:Studio 自己会扫库 */ } +} + /** 读配置:读不到就用默认值(配置只是让人能改,不该成为启动的前提)。 */ async function readConfig(pluginRoot) { const read = async (file) => { @@ -103,6 +185,28 @@ function expandHome(path) { return value.startsWith('~') ? join(homedir(), value.slice(1)) : value; } +/** 问一个已经跑着的 Studio 它是什么版本(/api/health 的 version)。 */ +function studioVersion(origin) { + return new Promise((resolve) => { + const url = new URL('/api/health', origin); + const req = httpRequest( + { host: url.hostname, port: url.port, path: url.pathname, method: 'GET', timeout: 4000, agent: false, + headers: { 'x-studio-key': 'probe' } }, + (response) => { + const chunks = []; + response.on('data', (c) => chunks.push(c)); + response.on('end', () => { + try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))?.version ?? null); } + catch { resolve(null); } + }); + }, + ); + req.on('timeout', () => { req.destroy(); resolve(null); }); + req.on('error', () => resolve(null)); + req.end(); + }); +} + /** 探一个 Studio:它的 /api/health 会不会答。答了就说明地址与 key 都还管用。 */ function probeStudio(origin, key, timeoutMs = PROBE_TIMEOUT_MS) { return new Promise((resolve) => { @@ -134,18 +238,6 @@ async function readRunFile(path) { 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; @@ -171,12 +263,11 @@ export async function start(context) { const { pluginRoot, listen, signal, logger } = context; const config = await readConfig(pluginRoot); - /* 这个实例自己那份 Studio 的 home 与端口:默认放在 Host 给的 dataDir 里(仓库硬规则: + /* 这个实例自己那份 Studio 的 home:默认放在 Host 给的 dataDir 里(仓库硬规则: 持久状态归 context.dataDir),不抢用户桌面 app 的 home。 - 测试可以用 miniapp.config.json 的 studioHome / studioPort 指到临时目录与别的端口。 */ + 测试可以用 miniapp.config.json 的 studioHome 指到临时目录。端口由系统分配,不用配。 */ 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,根本到不了这里。 @@ -189,7 +280,17 @@ export async function start(context) { */ const flavor = config.flavor === 'dev' ? 'dev' : 'release'; const userHome = USER_STUDIO_HOMES[flavor]; - const openfilmBin = DEFAULT_OPENFILM_BIN; // 固定 npx,不接受配置里的可执行文件 + /** + * 跑哪个 OpenFilm。 + * + * 有本地克隆就用克隆自己的 CLI(`node /packages/openfilm/bin/openfilm.mjs`): + * 版本一定跟克隆一致,不用联网,也绕开了 npx 的路径问题(npx 找不到缓存里的 bin 时会 + * 回退到 PATH 上的全局安装,于是跑成了另一个版本)。 + * 没有克隆才用 `npx -y openfilm@<固定版本>`。 + * + * 两种情况都在起完之后核对 Studio 的版本(见 studioVersion)。 + */ + const openfilmCli = await resolveOpenFilmCli(expandHome(config.openfilmRepo || process.env.OPENFILM_REPO || DEFAULT_OPENFILM_REPO)); /** * 桌面版模式:把桌面 app 的界面(编辑器 + 右边那栏聊天)跑在面板里。 @@ -221,7 +322,7 @@ export async function start(context) { try { stream.write(frame); } catch { /* 断了就断了 */ } } }; - const openfilmArgs = DEFAULT_OPENFILM_ARGS; // 固定 -y openfilm@0.2.5 + /** Studio 的现状:reused(我们上次起的,还在跑)还是 spawned(这次起的)。 */ const state = { @@ -236,6 +337,9 @@ export async function start(context) { bootLog: [], }; + /** 已经进入收尾:这之后 child 的 exit 是我们自己收的,不该被当成「它自己挂了」。 */ + let closing = false; + const log = (message) => { state.bootLog.push(String(message).slice(0, 200)); if (state.bootLog.length > 30) state.bootLog.shift(); @@ -243,14 +347,18 @@ export async function start(context) { }; /** - * 起一份 Studio(或者接上我们上次起的那份)。 + * 起一份 Studio(或者接上已经跑着的那一份)。 * - * 走 `openfilm open`:它认自己的 home,会把地址打到 stdout。 - * 但它会**复用**已经跑着的那个(按它自己 home 的 run.json)—— 那可能是用户桌面 app 的 - * Studio(Electron 专用编辑器,浏览器里是白的)。所以先探:能答、且不是 app 自己的,才复用。 + * 复用与否看 run.json:探 /api/health 能答、且**版本和我们要起的那份一致**,才接上去; + * 否则自己起一份(`startStudio`,端口交给系统分配)。 + * 版本这道关是必要的:机器上可能还留着上一版、或全局装的 Studio,路由与 handler 都不一样。 */ async function ensureStudio() { if (state.ready) return; + /* Studio 的项目列表来自它自己 home 的 studio.json;面板 GUI 的项目列表来自用户 home + 的 studio.json(USER_STUDIO_HOMES[flavor])。两边要一致,否则编辑器里 /api/projects/ + 会 404、预览也出不来。开工前把用户那份(连同 folders)抄到面板 home。 */ + await syncProjectList(userHome, STUDIO_HOME); const run = await readRunFile(join(STUDIO_HOME, 'run.json')); if (run) { @@ -267,10 +375,10 @@ export async function start(context) { log(`the Studio at ${run.origin} did not answer (${probe.error || probe.status}); starting one`); } - // 自己起一个。`openfilm open` 会把带 key 的地址打到 stdout。 + // 自己起一个(见下面的 startStudio)。 // 自己的 home ⇒ 它写自己的 run.json,不抢用户的;自己的库 ⇒ 它跟用户那份是两份数据。 // - // **两个面板同时开时只许起一个**(它们抢同一个端口、同一份 run.json): + // **两个面板同时开时只许起一个**(它们共用同一份 run.json): // 锁文件放在自己那份 home 里,谁先拿到锁谁起,另一个等它起来后复用。 await mkdir(STUDIO_HOME, { recursive: true }).catch(() => {}); const lock = join(STUDIO_HOME, 'starting.lock'); @@ -279,6 +387,13 @@ export async function start(context) { if (!run) return false; const probe = await probeStudio(run.origin, run.key); if (!probe.ok) return false; + /* 版本对不上就不接:一个更老的 Studio(全局装的、或上一版留下的)会让界面和 + 面板的预期对不上(路由、handler 都不一样)。 */ + const running = await studioVersion(run.origin); + if (running && running !== openfilmCli.version) { + log(`the Studio at ${run.origin} is ${running}, not ${openfilmCli.version}; not using it`); + return false; + } state.mode = 'reused'; state.origin = run.origin; state.key = run.key; @@ -303,7 +418,7 @@ export async function start(context) { // 先看有没有能用的(前一个面板留下的) if (await adopt(await readRunFile(join(STUDIO_HOME, 'run.json')), 'left by an earlier panel')) return; - // 没有就抢锁起一个。**两个面板同时开时只许起一个**(它们抢同一个端口、同一份 run.json)。 + // 没有就抢锁起一个。**两个面板同时开时只许起一个**(它们共用同一份 run.json)。 let got = false; for (let attempt = 0; attempt < 240; attempt++) { if (await take()) { got = true; break; } @@ -322,7 +437,21 @@ export async function start(context) { return; } - const child = spawn(openfilmBin, [...openfilmArgs, 'open'], { + /* 直接起 startStudio:绕过 `openfilm open` 的复用/更新检查/开浏览器那套。 */ + let serverModule = openfilmCli.serverMjs ? pathToFileURL(openfilmCli.serverMjs).href : null; + if (!serverModule) { + try { serverModule = await resolveNpxServer(); } + catch (e) { state.error = `could not resolve the pinned OpenFilm: ${e.message}`; log(state.error); await releaseLock(); return; } + } + /* port: 0 —— 让系统挑一个空闲端口。写死端口会在「机器上已有别的 OpenFilm」或「另一个面板在跑」 + 时撞 EADDRINUSE;挑出来的真实端口它会写进自己 home 的 run.json,我们读那个。 */ + /* 这个 worker 什么都不打印:stdout 属于 Host(仓库的静态检查 ENTRY_STDOUT 也不许入口碰它)。 + 它起好之后会自己写 run.json(advertise: true),父进程读那个(见下面的等待)。 */ + const workerCode = ` + import { startStudio } from ${JSON.stringify(serverModule)}; + await startStudio({ port: 0, advertise: true, openBrowser: () => {} }); + `; + const child = spawn(process.execPath, ['--input-type=module', '-e', workerCode], { cwd: here, env: { PATH: process.env.PATH ?? '', @@ -331,48 +460,48 @@ export async function start(context) { TMPDIR: process.env.TMPDIR ?? '/tmp', OPENFILM_NO_BROWSER: '1', OPENFILM_HOME: STUDIO_HOME, - OPENFILM_STUDIO_PORT: String(STUDIO_PORT), + /* 面板在 MiniMax Code 里跑时,process.execPath 是 Electron 的 Helper 二进制,不是 node。 + 直接拿它跑 -e 会变成「再起一个 Electron」,报 Unable to find helper app。 + 这个变量让 Electron 以纯 Node 方式运行;在真正的 node 下它被忽略,所以测试环境不受影响。 */ + ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}), }, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ['ignore', 'ignore', 'pipe'], detached: true, }); state.mode = 'spawned'; state.child = child; log(`started openfilm (pid ${child.pid})`); + /* 它的输出只有错误有用(正常起来时什么都不打印,stdout 已经丢掉了)。 */ 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', async (code) => { + child.on('exit', (code) => { log(`openfilm exited (${code})`); - /* `openfilm open` 复用已有 Studio 时可能不打印地址就退出:按 run.json 接上。 */ - if (!state.ready) { - const adopted = await adopt(await readRunFile(join(STUDIO_HOME, 'run.json')), 'it exited after reusing a Studio'); - if (adopted) return; - const tail = out.trim().split('\n').filter(Boolean).slice(-2).join(' | ').slice(0, 200); - state.error = state.error || `openfilm exited (${code})${tail ? ': ' + tail : ''}`; - } + if (closing) return; // 我们自己收的,不是它挂了 + const tail = out.trim().split('\n').filter(Boolean).slice(-2).join(' | ').slice(0, 200); + /* 起来了又挂:把 ready 收回,不然面板一直以为它还能用,界面只是一片空白。 */ + state.ready = false; + state.origin = null; + state.key = null; + state.error = state.error || `OpenFilm stopped (exit ${code})${tail ? ': ' + tail : ''}`; }); - // 等它把地址吐出来 + /* 等它把自己写进 run.json(advertise: true)。不靠 stdout 握手:stdout 属于 Host。 + run.json 里的 pid 正好是这个 child,所以「这份是刚写的」判得出来,不是上一轮留下的。 */ const until = Date.now() + BOOT_TIMEOUT_MS; while (!state.ready && Date.now() < until) { + const run = await readRunFile(join(STUDIO_HOME, 'run.json')); + if (run && run.pid === child.pid) { + state.origin = run.origin; + state.key = run.key; + state.ready = true; + state.readyAt = Date.now(); + state.error = null; + log(`the Studio is at ${run.origin}`); + break; + } + if (state.error) break; await new Promise((done) => setTimeout(done, 250)); } if (!state.ready) { @@ -480,6 +609,12 @@ export async function start(context) { return { groups, library: flavor }; } + /** Studio 记得的项目文件夹。IPC 里要拿它当白名单用:读不到就返回空,宁可拒绝。 */ + async function projectFolders() { + const body = await studioGet('/api/projects'); + return (body?.projects ?? []).filter((p) => !p.missing).map((p) => String(p.path)); + } + /** 项目自己的 id(Studio 算的,存在项目的 .film/id 里)。 */ async function projectId(path) { try { @@ -520,7 +655,7 @@ export async function start(context) { studioOrigin: state.origin, studioKey: state.key, openfilmHome: STUDIO_HOME, - openfilmVersion: (DEFAULT_OPENFILM_ARGS.find((a) => a.includes('openfilm@')) || '').split('@')[1] || '0.2.5', + openfilmVersion: openfilmCli.version, tell, logger, }); @@ -717,6 +852,7 @@ export async function start(context) { 'desktop:tabs:retitle': (p) => obj(p) && str(p.id) && str(p.title), 'desktop:tabs:open': (p) => obj(p) && str(p.to), 'desktop:agents:list': () => true, + 'desktop:agents:refresh': () => true, 'desktop:agents:report': () => true, 'desktop:agents:disconnect': (p) => obj(p) && str(p.id), 'desktop:agents:usage': (p) => obj(p) && str(p.id), @@ -754,6 +890,7 @@ export async function start(context) { 'desktop:chat:unsaved': (p) => obj(p) && str(p.project), 'desktop:chat:snapshot': (p) => obj(p) && str(p.project) && str(p.turnId), 'desktop:chat:changes': (p) => obj(p) && str(p.project) && str(p.turnId), + 'desktop:chat:undo': (p) => obj(p) && str(p.project) && str(p.turnId), 'desktop:skills:list': (p) => obj(p) && str(p.project), 'desktop:skills:texts': (p) => obj(p) && str(p.project) && Array.isArray(p.names), 'desktop:skills:changed': () => true, @@ -809,6 +946,17 @@ export async function start(context) { response.end(JSON.stringify({ error: 'bad payload', channel })); return; } + /* 起本地 agent 的 cwd 是页面给的。只许项目文件夹: + 否则一个页面就能让 agent 在 / 或家目录里跑,读写范围远超这个面板该碰的东西。 */ + if (channel === 'desktop:local-agents:start') { + const cwd = typeof payload?.cwd === 'string' ? payload.cwd : ''; + const folders = await projectFolders(); + if (!folders.some((root) => cwd === root || cwd.startsWith(root + sep))) { + response.writeHead(403, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'not a project folder', channel })); + return; + } + } try { const result = await handler({ sender: desktop.sender }, payload); response.writeHead(200, { 'content-type': 'application/json' }); @@ -851,14 +999,40 @@ export async function start(context) { /** * Studio 的 /api/* 只放这些前缀(编辑器真正用的)。 - * 管理类的不放:/api/quit、/api/update、/api/open、/api/keys 等一律 403。 + * 管理类的不放:/api/quit、/api/update、/api/open、/api/desktop 等一律 403。 */ + /* Studio 的 /api/*:路由 + 方法都按 0.3.0 的界面真正用到的来。 + 不放:/api/get(生成媒体,要花钱)、/api/providers(你的服务密钥)、 + /api/update、/api/quit、/api/open、/api/desktop(管理类)。 */ const STUDIO_API_ALLOW = [ - '/api/health', '/api/projects', '/api/files', '/api/doctor', '/api/library', - '/api/saved', '/api/skills', '/api/prefs', '/api/examples', '/api/fonts', - '/api/page-links', '/api/exports', '/api/look', + { p: '/api/health', m: ['GET'] }, + { p: '/api/version', m: ['GET'] }, + { p: '/api/projects', m: ['GET', 'POST'] }, + { p: '/api/files', m: ['GET'] }, + { p: '/api/page-links', m: ['GET'] }, + { p: '/api/exports', m: ['GET', 'POST'] }, + { p: '/api/look', m: ['GET'] }, + { p: '/api/doctor', m: ['GET'] }, + { p: '/api/diagnostics', m: ['GET'] }, + { p: '/api/library', m: ['GET', 'POST'] }, + { p: '/api/canvas', m: ['GET', 'POST'] }, + { p: '/api/canvas-templates', m: ['GET', 'POST'] }, + { p: '/api/canvas-characters', m: ['GET', 'POST'] }, + { p: '/api/kept', m: ['GET'] }, + { p: '/api/local-models', m: ['GET'] }, + { p: '/api/saved', m: ['GET', 'POST'] }, + { p: '/api/skills', m: ['GET'] }, + { p: '/api/prefs', m: ['GET', 'POST'] }, + { p: '/api/examples', m: ['GET'] }, + { p: '/api/fonts', m: ['GET'] }, + { p: '/api/browse', m: ['GET'] }, + { p: '/api/locations', m: ['GET'] }, + { p: '/api/pick-folder', m: ['POST'] }, + { p: '/api/pick-files', m: ['POST'] }, ]; - const allowStudioApi = (path) => STUDIO_API_ALLOW.some((p) => path === p || path.startsWith(p + '/')); + const allowStudioApi = (path, method) => STUDIO_API_ALLOW.some( + (e) => (path === e.p || path.startsWith(e.p + '/')) && e.m.includes(method), + ); /** 只放这几个头过去:不转发 cookie、不转发别人的鉴权头。 */ const studioHeaders = (request) => ({ ...(request.headers.accept ? { accept: request.headers.accept } : {}), @@ -874,9 +1048,9 @@ export async function start(context) { response.end(JSON.stringify({ error: 'OpenFilm is not up yet' })); return; } - if (!allowStudioApi(url.pathname)) { + if (!allowStudioApi(url.pathname, request.method)) { response.writeHead(403, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ error: 'route not allowed', path: url.pathname })); + response.end(JSON.stringify({ error: 'route not allowed', path: url.pathname, method: request.method })); return; } const chunks = []; @@ -1179,6 +1353,7 @@ export async function start(context) { if (disposal) return disposal; context.signal.removeEventListener('abort', onAbort); disposal = (async () => { + closing = true; await releaseLock(); /** * 只关**自己起的那份**。 @@ -1190,20 +1365,14 @@ export async function start(context) { try { desktop.localAgents?.closeAll?.(); } catch { /* 收不干净也不挡收尾 */ } if (state.mode === 'spawned') { - /* `openfilm open` 自己退出后,Studio 继续活着(它才是那个服务)—— - 它的 pid 写在**自己那份 home** 的 run.json 里,不是我们 spawn 的那个进程的 pid。 */ - const run = await readRunFile(join(STUDIO_HOME, 'run.json')); const quit = state.ready ? await quitStudio() : null; if (quit?.status === 200) log('asked the Studio to quit'); else if (quit?.status === 423) log('the Studio is the app\'s own; left it running'); - else { - /* 叫不动就杀:run.json 里的 pid 是 Studio 自己的,killTree 把它的孩子一起收走 */ - if (run?.pid) { - killTree({ pid: run.pid }); - log('asked the Studio to quit by signal'); - } else if (state.child?.pid) { - killTree(state.child); - } + else if (state.child?.pid) { + /* 只杀**我们自己 spawn 的那个**。run.json 可能已经被后起的一份覆盖, + 照它里面的 pid 杀会打掉别的面板的 Studio。 */ + killTree(state.child); + log('asked the Studio to quit by signal'); } await new Promise((r) => setTimeout(r, 800)); } diff --git a/plugins/cristinamadureira353-svg/openfilm/skills/openfilm/SKILL.md b/plugins/cristinamadureira353-svg/openfilm/skills/openfilm/SKILL.md index 9fbbee5e..e7b95084 100644 --- a/plugins/cristinamadureira353-svg/openfilm/skills/openfilm/SKILL.md +++ b/plugins/cristinamadureira353-svg/openfilm/skills/openfilm/SKILL.md @@ -5,7 +5,7 @@ description: OpenFilm inside MiniMax Code. The panel is the OpenFilm desktop int # OpenFilm panel (Mini App) -The official OpenFilm release (`openfilm@0.2.5`) is installed as a Mini App. The panel is OpenFilm itself. +The official OpenFilm release (`openfilm@0.3.0`) is installed as a Mini App. The panel is OpenFilm itself. ## What the panel is @@ -22,7 +22,7 @@ A MiniApp can only be a page + a Node process, so the Electron window cannot be - Studio's `/api/*` and its WebSocket event stream are proxied same-origin, with the key attached on the server side only. The API proxy allows only the route prefixes the editor uses (no `quit`/`update`/`open`/`keys`), and forwards only `accept`, `content-type` and `range`. WebSocket upgrades are limited to the two event streams. - Film pages and media are proxied under `/p/*`: the embedded editor origin and Studio's `folder` URLs are rewritten to the panel's origin, and the film bridge's same-origin early return is disabled in the proxied copy. Without this the preview stays on "drawing the first frame". - Settings → Agents and the chat's agent menu use the **roster** (`agent-roster.mjs` + `agents.mjs`), not the `localAgents` list; both are wired. -- **Security**: loopback authentication is done by the Host (MiniMax Code 3.1.2+). No secrets are injected into pages, client JS, logs, or error responses. IPC channels are an explicit allowlist with payload validation; installer, sign-in and `automatic` flows are not offered. `open-desktop` launches only fixed OpenFilm app locations, and OpenFilm is always started as `npx -y openfilm@0.2.5` with a minimal environment. State lives in `context.dataDir` (`openfilm-studio/`); the legacy `studioHome` override is documented in `## Data & access`. +- **Security**: loopback authentication is done by the Host (MiniMax Code 3.1.2+). No secrets are injected into pages, client JS, logs, or error responses. IPC channels are an explicit allowlist with payload validation; installer, sign-in and `automatic` flows are not offered. `open-desktop` launches only fixed OpenFilm app locations. Studio is started through upstream's `startStudio` (OS-assigned port, `advertise: true`, no browser) from the clone named by `openfilmRepo`; when there is no clone, the pinned `openfilm@0.3.0` from npm is used instead. Always with a minimal environment. State lives in `context.dataDir` (`openfilm-studio/`); the legacy `studioHome` override is documented in `## Data & access`. - **Cleanup**: `dispose` closes local agent sessions, ends SSE streams, and quits only the Studio this instance spawned. Desktop handlers are not published to npm (`@openfilm/desktop`), so desktop mode needs the clone (`openfilmRepo`) and one build: diff --git a/plugins/cristinamadureira353-svg/openfilm/test/accept-miniapp.mjs b/plugins/cristinamadureira353-svg/openfilm/test/accept-miniapp.mjs index e45eb68d..5f7d3300 100644 --- a/plugins/cristinamadureira353-svg/openfilm/test/accept-miniapp.mjs +++ b/plugins/cristinamadureira353-svg/openfilm/test/accept-miniapp.mjs @@ -2,7 +2,7 @@ * OpenFilm MiniApp 的完整验收。 * * 跑法:node test/accept-miniapp.mjs - * 全部实例都用**临时的 home 和端口**(4900–4903),跑完自己收掉; + * 全部实例都用**临时的 home 和系统分配的空闲端口**,跑完自己收掉; * 不碰你正在用的面板,也不写你的项目(项目列表只读)。 * 没有 OpenFilm 项目的机器上,与项目相关的两项检查会明确跳过。 * @@ -72,7 +72,10 @@ async function boot(pluginRoot, port, studioHome) { const waitReady = async () => { for (let i = 0; i < 240; i++) { const { data } = await json('/miniapp/studio'); - if (data?.ready) return data; + /* Studio 起来之后,桌面版的 handler 才异步接上(server.mjs 里 ensureStudio().then(ensureDesktopHost)), + 所以「就绪」要等到桌面侧也有个结论:接上了(ready),或者报错了(error)。只看 data.ready 会抢跑。 */ + const settled = data?.desktop?.on === false || Boolean(data?.desktop?.ready) || Boolean(data?.desktop?.error); + if (data?.ready && settled) return data; await sleep(500); } return null; @@ -115,10 +118,14 @@ const portFree = (port) => { /* ───────────────────────── 1. 主实例:桌面版模式 ───────────────────────── */ console.log('\n【1】桌面版模式(主实例)'); -const mainPkg = await tempPackage({ studioPort: await freePort() }); +const mainPkg = await tempPackage({}); const main = await boot(mainPkg.root, await freePort(), mainPkg.studioHome); const studio = await main.waitReady(); check('Studio 起来', Boolean(studio), studio ? `mode=${studio.mode}` : '超时'); +{ + const h = await main.json('/api/health'); + check('Studio 是钉住的 0.3.0(不是机器上别的版本)', h.data?.version === '0.3.0', `version=${h.data?.version}`); +} check('桌面模式就绪', studio?.desktop?.ready === true, JSON.stringify(studio?.desktop ?? {})); const tree = await main.json('/miniapp/tree'); @@ -196,6 +203,8 @@ console.log('\n【2b】评审要求的安全边界'); check('/api/quit 被白名单挡住', quit.status === 403, `HTTP ${quit.status}`); const open = await main.call('/api/open'); check('/api/open 被白名单挡住', open.status === 403, `HTTP ${open.status}`); + const looseCwd = await main.ipc('desktop:local-agents:start', { agent: 'codex', cwd: '/' }); + check('本地 agent 只许在项目文件夹里跑', looseCwd.status === 403, `HTTP ${looseCwd.status}`); const auto = await main.ipc('desktop:agents:automatic', { id: 'codex' }); check('automatic 流程被 IPC 白名单挡住', auto.status === 403, `HTTP ${auto.status}`); const bad = await main.ipc('desktop:agents:shown', { id: 'codex', shown: 'yes' }); @@ -223,12 +232,23 @@ await rm(mainPkg.root, { recursive: true, force: true }); /* ───────────────────────── 2. 降级:desktopMode false ───────────────────────── */ console.log('\n【4】降级:关掉桌面模式就是网页版'); -const webPort = await freePort(); -const web = await tempPackage({ desktopMode: false, studioPort: webPort }); +const web = await tempPackage({ desktopMode: false }); +/* 面板自己那份项目清单不能被用户那份盖掉:先只往面板 home 里塞一个项目,起完之后看它还在不在。 */ +const ownOnly = await mkdtemp(join(tmpdir(), 'of-own-')); +await mkdir(join(ownOnly, '.film'), { recursive: true }); +await writeFile(join(ownOnly, '.film', 'id'), 'OwnOnlyProject01\n'); +await mkdir(web.studioHome, { recursive: true }); +await writeFile(join(web.studioHome, 'studio.json'), JSON.stringify({ recent: [{ id: 'OwnOnlyProject01', path: ownOnly, openedAt: Date.now() }], folders: [] }, null, 2)); const webApp = await boot(web.root, await freePort(), web.studioHome); const webStudio = await webApp.waitReady(); check('网页模式 Studio 起来', Boolean(webStudio)); check('desktop.on = false', webStudio?.desktop?.on === false); +{ + const wt = await webApp.json('/miniapp/tree'); + const paths = (wt.data?.groups ?? []).flatMap((g) => [g.project.path, ...(g.children ?? []).map((c) => c.project.path)]); + check('面板自己的项目没被用户那份盖掉(合并而不是覆盖)', paths.includes(ownOnly)); + if (firstProject) check('用户那份的项目也还在(两边合并)', paths.includes(firstProject.path), `${paths.length} 个项目`); +} if (firstProject) { const wp = await webApp.call('/projects/' + firstProject.id); check('网页模式编辑器也同源伺服(不带 key)', wp.status === 200 && !wp.text.includes('/open?key='), `HTTP ${wp.status}`); @@ -236,13 +256,15 @@ if (firstProject) { const noBridge = await webApp.call('/bridge-web.js'); check('网页模式不伺服**我们的**桥(返回的是 Studio 编辑器)', !noBridge.text.includes('openfilmNative'), `HTTP ${noBridge.status}`); await webApp.app.dispose(); -check('网页模式 dispose 后端口空了', portFree(webPort)); +/* 端口是系统挑的(port: 0),所以按它自己报的 origin 去查,而不是测试开头写死的那个。 */ +const webOriginPort = Number(new URL(webStudio?.origin ?? 'http://127.0.0.1:0').port); +check('网页模式 dispose 后端口空了', webOriginPort > 0 && portFree(webOriginPort), `port ${webOriginPort}`); await rm(web.root, { recursive: true, force: true }); +await rm(ownOnly, { recursive: true, force: true }); /* ───────────────────────── 3. 桌面版没构建:要报错,不是白屏 ───────────────────────── */ console.log('\n【5】桌面版页面没构建时'); -const brokenPort = await freePort(); -const broken = await tempPackage({ desktopMode: true, shellDist: '/nonexistent-of-shell', studioPort: brokenPort }); +const broken = await tempPackage({ desktopMode: true, shellDist: '/nonexistent-of-shell' }); const brokenApp = await boot(broken.root, await freePort(), broken.studioHome); await brokenApp.waitReady(); let brokenStudio = null; @@ -260,15 +282,14 @@ await rm(broken.root, { recursive: true, force: true }); /* ───────────────────────── 4. 生命周期:幂等 + 并发 + 收尾 ───────────────────────── */ console.log('\n【6】两个面板同时开:只许起一个 Studio'); -const lifeStudioPort = await freePort(); -const life = await tempPackage({ desktopMode: false, studioPort: lifeStudioPort }); +const life = await tempPackage({ desktopMode: false }); const a = await boot(life.root, await freePort(), life.studioHome); const b = await boot(life.root, await freePort(), life.studioHome); const [sa, sb] = await Promise.all([a.waitReady(), b.waitReady()]); check('两个都起来了', Boolean(sa && sb), `A=${sa?.mode} B=${sb?.mode}`); check('一个 spawned 一个 reused', new Set([sa?.mode, sb?.mode]).size === 2 || sa?.mode === 'reused' && sb?.mode === 'reused', `A=${sa?.mode} B=${sb?.mode}`); -if (hasLsof) { +if (hasLsof && sa?.origin) { const listeners = execFileSync('bash', ['-c', `lsof -nP -iTCP:${new URL(sa.origin).port} -sTCP:LISTEN -t | sort -u | wc -l`], { encoding: 'utf8' }).trim(); check('只起一个 Studio 进程', listeners === '1', `${listeners} 个`); } else { @@ -282,7 +303,9 @@ check('A dispose(SSE 开着)不挂', disposeA === 'done', disposeA); await aSse.body.cancel().catch(() => {}); const disposeB = await Promise.race([b.app.dispose().then(() => 'done'), sleep(15000).then(() => 'timeout')]); check('B dispose 完成', disposeB === 'done', disposeB); -check('两份都收了之后没有 Studio 留下', portFree(lifeStudioPort)); +/* 同上:按 Studio 自己报的 origin 端口查,不按测试写死的端口。 */ +const lifeOriginPort = Number(new URL(sa?.origin ?? 'http://127.0.0.1:0').port); +check('两份都收了之后没有 Studio 留下', lifeOriginPort > 0 && portFree(lifeOriginPort), `port ${lifeOriginPort}`); /* 如果这次复用的是上一轮留下的 Studio(reused),顺手收掉它,免得测试越积越多。 只收高位端口的测试 Studio,4823 那个真面板的一律不碰。 */ for (const origin of [sa?.origin, sb?.origin]) {