fix(tokens): импорт неигровых токенов из zip и плавные пути на macOS

Полный импорт/открытие проекта подхватывает app-tokens. Движение по путям пишется в DOM без ре-рендера кадра, у пульта и презентации выключен background throttling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-07 16:16:30 +08:00
parent 586166cacd
commit fdccc0deb5
16 changed files with 285 additions and 111 deletions
+22 -3
View File
@@ -21,6 +21,7 @@ import {
import type { Project } from '../shared/types';
import { asNpcId } from '../shared/types/ids';
import { listVisibleSceneNpcTokens } from '../shared/types/appPlayers';
import { remapProjectSceneTokenIds } from '../shared/graph/storylineExportImport';
import {
asPreviewRotationDeg,
previewRotationStepsCw,
@@ -390,6 +391,18 @@ function emitTokensState(): void {
}
}
/** Подтянуть `app-tokens/` из открытого кэша (экспорт сюжетной линии / полный zip). */
async function importAppTokensFromOpenProjectCache(): Promise<Project | null> {
const cacheDir = projectStore.getOpenCacheDir();
if (!tokensStore || !cacheDir) return null;
const remap = await tokensStore.importFromExportDir(cacheDir);
if (remap.size === 0) return null;
emitTokensState();
const needsRemap = [...remap.entries()].some(([from, to]) => from !== to);
if (!needsRemap) return null;
return projectStore.updateProject((p) => remapProjectSceneTokenIds(p, remap));
}
function emitSceneTokensSessionState(): void {
const state = sceneTokensSessionStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
@@ -882,7 +895,9 @@ async function main() {
return { project };
});
registerHandler(ipcChannels.project.open, async ({ projectId }) => {
const project = await projectStore.openProjectById(projectId);
let project = await projectStore.openProjectById(projectId);
const remapped = await importAppTokensFromOpenProjectCache();
if (remapped) project = remapped;
sceneViewStore.reset();
sceneTokensSessionStore.reset();
sceneNpcTokensSessionStore.reset();
@@ -1445,7 +1460,7 @@ async function main() {
}
const srcPath = filePaths[0];
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' });
const project = await projectStore.importProjectFromExternalZip(srcPath, (p) => {
let project = await projectStore.importProjectFromExternalZip(srcPath, (p) => {
emitZipProgress({
kind: 'import',
stage: p.stage,
@@ -1454,6 +1469,8 @@ async function main() {
});
});
const cacheDir = projectStore.getOpenCacheDir();
const remapped = await importAppTokensFromOpenProjectCache();
if (remapped) project = remapped;
if (playlistsStore && cacheDir) {
await playlistsStore.importFromExportDir(cacheDir);
emitPlaylistsState();
@@ -1564,7 +1581,7 @@ async function main() {
);
registerHandler(ipcChannels.project.importZipFromPath, async ({ filePath }) => {
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' });
const project = await projectStore.importProjectFromExternalZip(filePath, (p) => {
let project = await projectStore.importProjectFromExternalZip(filePath, (p) => {
emitZipProgress({
kind: 'import',
stage: p.stage,
@@ -1573,6 +1590,8 @@ async function main() {
});
});
const cacheDir = projectStore.getOpenCacheDir();
const remapped = await importAppTokensFromOpenProjectCache();
if (remapped) project = remapped;
if (playlistsStore && cacheDir) {
await playlistsStore.importFromExportDir(cacheDir);
emitPlaylistsState();
@@ -122,6 +122,23 @@ void test('main index: setCurrentGraphNode uses setSessionPointer not updateProj
assert.doesNotMatch(m[0], /updateProject/);
});
void test('main index: full zip import loads app-tokens like playlists', () => {
const src = fs.readFileSync(path.join(here, '..', 'index.ts'), 'utf8');
assert.match(src, /async function importAppTokensFromOpenProjectCache/);
assert.match(src, /tokensStore\.importFromExportDir\(cacheDir\)/);
assert.match(src, /remapProjectSceneTokenIds/);
const importZip = src.match(
/registerHandler\(ipcChannels\.project\.importZip,[\s\S]*?return \{ canceled: false as const, project \}/,
);
assert.ok(importZip);
assert.match(importZip[0], /importAppTokensFromOpenProjectCache/);
const importFromPath = src.match(
/registerHandler\(ipcChannels\.project\.importZipFromPath,[\s\S]*?return \{ project \}/,
);
assert.ok(importFromPath);
assert.match(importFromPath[0], /importAppTokensFromOpenProjectCache/);
});
void test('atomicReplace: replaceFileAtomic must not rm destination before successful commit', () => {
const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8');
const i = src.indexOf('export async function replaceFileAtomic');
@@ -113,3 +113,10 @@ void test('createWindows: логи окон не валят main через EPIP
assert.ok(src.includes('errorCode === -3'));
assert.ok(src.includes('isMainFrame'));
});
void test('createWindows: presentation/control не троттлят RAF в фоне (macOS paths)', () => {
const src = readCreateWindows();
assert.match(src, /kind === 'presentation' \|\| kind === 'control'/);
assert.match(src, /backgroundThrottling:\s*false/);
assert.match(src, /setBackgroundThrottling\(false\)/);
});
+7
View File
@@ -398,6 +398,10 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
autoplayPolicy: 'no-user-gesture-required',
// file:// + бандл Vite: без этого на Windows часто не грузятся чанки; http:// (dev server) оставляем строгим.
webSecurity: Boolean(process.env.VITE_DEV_SERVER_URL),
// macOS throttles RAF in unfocused windows — token paths and video stutter on presentation.
...(kind === 'presentation' || kind === 'control' || kind === 'tokenPathEditor'
? { backgroundThrottling: false }
: {}),
...(kind === 'npcs'
? {
webviewTag: true,
@@ -416,6 +420,9 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
}
bindWindowChromeTitle(win, kind);
if (kind === 'presentation' || kind === 'control' || kind === 'tokenPathEditor') {
win.webContents.setBackgroundThrottling(false);
}
if (kind === 'npcs') {
win.webContents.on('will-attach-webview', (event, guestPrefs, params) => {
delete guestPrefs.preload;