/** * 「在 Figma 中导入并打开」:把工单挂着的 Figma 插件 manifest.json 写进 * Figma 桌面版的已导入插件清单,然后拉起 Figma。 * * 为什么要自己改配置文件:Figma 没有命令行导入开发插件的入口,UI 上得 * 「Plugins → Development → Import plugin from manifest」一路点。清单本身 * 就是 `~/Library/Application Support/Figma/settings.json` 里的一个数组, * 直接往里加一条即可。 * * 最大的坑:**Figma 退出时会用内存里的状态整个覆盖 settings.json**。开着 * Figma 写进去,用户一关 Figma 就全没了。所以写之前必须先把 Figma 关掉, * 而且要等进程真的退出——不弹窗问用户,问了也只能答「关」。 * * 只支持 macOS:路径和 `osascript` / `open -a` 都是 mac 专有。 */ import { execFile } from 'node:child_process' import * as fs from 'node:fs' import { promises as fsp } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import process from 'node:process' import { env, window } from 'vscode' import { logger } from '../logging/logger' import { appendManifestEntry, asExtensionEntries } from './localExtensions' /** Figma 桌面版的设置文件(含已导入开发插件清单)。 */ function figmaSettingsPath(): string { return path.join(os.homedir(), 'Library', 'Application Support', 'Figma', 'settings.json') } /** 等 Figma 退出的上限;到点还没退就让用户自己关,绝不 kill -9。 */ const QUIT_TIMEOUT_MS = 10_000 const QUIT_POLL_MS = 400 function run(cmd: string, args: string[]): Promise<{ code: number, stdout: string, stderr: string }> { return new Promise((resolve) => { execFile(cmd, args, { timeout: 15_000 }, (err, stdout, stderr) => { // pgrep 没匹配到进程也是非零退出,所以这里不把非零当失败,交给调用方判。 const code = err ? Number((err as NodeJS.ErrnoException).code ?? 1) : 0 resolve({ code: Number.isFinite(code) ? code : 1, stdout: (stdout ?? '').toString(), stderr: (stderr ?? '').toString(), }) }) }) } async function isFigmaRunning(): Promise { const { stdout } = await run('pgrep', ['-x', 'Figma']) return stdout.trim().length > 0 } /** * 优雅退出 Figma 并轮询等它真的没了。超时返回 false(调用方报错让用户手动关), * 不强杀——Figma 被 SIGKILL 会丢用户没保存的画布。 */ async function quitFigmaAndWait(): Promise { await run('osascript', ['-e', 'quit app "Figma"']) const deadline = Date.now() + QUIT_TIMEOUT_MS while (Date.now() < deadline) { if (!await isFigmaRunning()) return true await new Promise(resolve => setTimeout(resolve, QUIT_POLL_MS)) } return !await isFigmaRunning() } /** 读 manifest.json 的 `name` 当 Figma 里显示的插件名;没有就用目录名兜底。 */ async function readPluginName(manifestAbs: string): Promise { const raw = await fsp.readFile(manifestAbs, 'utf8') const parsed: unknown = JSON.parse(raw) const name = parsed && typeof parsed === 'object' ? (parsed as { name?: unknown }).name : undefined if (typeof name === 'string' && name.trim().length > 0) return name.trim() return path.basename(path.dirname(manifestAbs)) } function fail(message: string, err?: unknown): void { const details = err === undefined ? undefined : err instanceof Error ? (err.stack ?? err.message) : String(err) logger.add({ level: 'error', source: 'figma', message, details }) void window.showErrorMessage(message) } /** * 主流程。`manifestPath` 可以是仓库相对路径也可以是绝对路径。 * 任何一步失败都出错误提示 + 记日志,不静默。 */ export async function importPluginIntoFigma(opts: { issueNumber: number manifestPath: string workspaceRoot: string }): Promise { const { issueNumber, manifestPath, workspaceRoot } = opts const manifestAbs = path.isAbsolute(manifestPath) ? manifestPath : path.join(workspaceRoot, manifestPath) if (!fs.existsSync(manifestAbs)) { fail(`#${issueNumber} 的原型插件不存在:${manifestAbs}`) return } // 非 mac 没有这条路径可走,把绝对路径给用户,让他自己在 Figma 里导入。 if (process.platform !== 'darwin') { await env.clipboard.writeText(manifestAbs) void window.showWarningMessage( `当前系统不支持自动导入 Figma 插件,manifest 路径已复制到剪贴板,请在 Figma 里手动导入:${manifestAbs}`, ) return } const settingsPath = figmaSettingsPath() if (!fs.existsSync(settingsPath)) { fail(`没找到 Figma 配置文件:${settingsPath}。先装 Figma 桌面版并至少启动一次。`) return } let pluginName: string try { pluginName = await readPluginName(manifestAbs) } catch (err) { fail(`读取 manifest.json 失败:${manifestAbs}`, err) return } let settings: Record try { const parsed: unknown = JSON.parse(await fsp.readFile(settingsPath, 'utf8')) if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new TypeError('settings.json 顶层不是对象') settings = parsed as Record } catch (err) { fail(`读取 Figma 配置失败:${settingsPath}`, err) return } const entries = asExtensionEntries(settings.localFileExtensions) const result = appendManifestEntry(entries, { manifestPath: manifestAbs, name: pluginName }) // Figma 在跑就一律先关掉,两个原因都成立: // 写清单时——退出时的内存快照会把这次写入盖掉; // 清单里已经有了——Figma 把插件代码缓存在进程里,不重启就还跑上一版 // code.js,改完原型点这个按钮会看到旧界面。 if (await isFigmaRunning()) { logger.add({ level: 'info', source: 'figma', message: result.added ? '写配置前先退出 Figma(退出会覆盖 settings.json)' : '插件已在清单中,重启 Figma 让它重新读盘上最新的插件代码', }) if (!await quitFigmaAndWait()) { fail(`Figma 在 ${QUIT_TIMEOUT_MS / 1000} 秒内没有退出,请手动关掉 Figma 后重试。`) return } } if (result.added) { try { const backup = `${settingsPath}.bak-${Date.now()}` await fsp.copyFile(settingsPath, backup) settings.localFileExtensions = result.entries await fsp.writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8') logger.add({ level: 'info', source: 'figma', message: `#${issueNumber} 原型插件已写入 Figma 配置:${pluginName}`, details: `manifest=${manifestAbs} backup=${backup}`, }) } catch (err) { fail(`写入 Figma 配置失败:${settingsPath}`, err) return } } else { logger.add({ level: 'info', source: 'figma', message: `#${issueNumber} 原型插件已在 Figma 清单中,跳过写入:${result.name}`, details: `manifest=${manifestAbs}`, }) } const open = await run('open', ['-a', 'Figma']) if (open.code !== 0) { fail(`拉起 Figma 失败:${open.stderr.trim() || `exit ${open.code}`}`) return } void window.showInformationMessage( result.added ? `原型插件「${result.name}」已导入 Figma,在 Plugins > Development 里跑它` : `Figma 已重启,原型插件「${result.name}」是最新代码,在 Plugins > Development 里跑它`, ) }