chore(release): gate users-branch features for hotfix

Add USERS_BRANCH_FEATURES_ENABLED (off by default) to hide Players library, session tokens, and related UI while keeping the preview import fix. Restore primary Run button when the flag is off.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-04 10:15:28 +08:00
parent 4e6f7321b8
commit cbb6edc378
9 changed files with 148 additions and 59 deletions
+45 -10
View File
@@ -8,6 +8,7 @@ import { installStdoutEpipeGuards } from './safeConsole';
installStdoutEpipeGuards();
import { openDialogFilterLabel } from '../shared/appBranding';
import { USERS_BRANCH_FEATURES_ENABLED } from '../shared/features/usersBranchFeatures';
import { ipcChannels, type ScenePreviewImportEvent, type SessionState } from '../shared/ipc/contracts';
import {
PROJECT_ZIP_OPEN_DIALOG_FILTER,
@@ -402,8 +403,10 @@ async function main() {
const licenseService = new LicenseService(app.getPath('userData'));
tokensStore = new TokensStore(app.getPath('userData'));
await tokensStore.ensureLoaded();
if (USERS_BRANCH_FEATURES_ENABLED) {
playersStore = new PlayersStore(app.getPath('userData'));
await playersStore.ensureLoaded();
}
setLicenseAssert(() => {
licenseService.assertForIpc();
});
@@ -429,10 +432,12 @@ async function main() {
sceneTokensSessionStore.reset();
sceneNpcTokensSessionStore.reset();
scenePlayerTokensSessionStore.reset();
if (USERS_BRANCH_FEATURES_ENABLED) {
const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : [];
if (playerIds.length > 0) {
scenePlayerTokensSessionStore.dispatch({ kind: 'setSelection', playerIds });
}
}
effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() });
openMultiWindow();
const project = projectStore.getOpenProject();
@@ -1357,13 +1362,19 @@ async function main() {
});
registerHandler(ipcChannels.players.list, async () => {
await playersStore!.ensureLoaded();
return { players: playersStore!.listPlayers(), teams: playersStore!.listTeams() };
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
return { players: [], teams: [] };
}
await playersStore.ensureLoaded();
return { players: playersStore.listPlayers(), teams: playersStore.listTeams() };
});
registerHandler(
ipcChannels.players.upsert,
async ({ id, name, filePath, teamId, ringColor, imageOffset, imageScale }) => {
const player = await playersStore!.upsert(
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
throw new Error('Players library is disabled in this build');
}
const player = await playersStore.upsert(
{
name,
...(id !== undefined ? { id } : {}),
@@ -1380,17 +1391,26 @@ async function main() {
},
);
registerHandler(ipcChannels.players.delete, async ({ id }) => {
await playersStore!.delete(id);
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
return { ok: true };
}
await playersStore.delete(id);
emitPlayersState();
return { ok: true };
});
registerHandler(ipcChannels.players.setOrder, async ({ playerIds }) => {
const players = await playersStore!.setPlayersOrder(playerIds);
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
return { players: [] };
}
const players = await playersStore.setPlayersOrder(playerIds);
emitPlayersState();
return { players };
});
registerHandler(ipcChannels.players.upsertTeam, async ({ id, name, color }) => {
const team = await playersStore!.upsertTeam({
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
throw new Error('Players library is disabled in this build');
}
const team = await playersStore.upsertTeam({
name,
...(id !== undefined ? { id } : {}),
...(color !== undefined ? { color } : {}),
@@ -1399,21 +1419,33 @@ async function main() {
return { team };
});
registerHandler(ipcChannels.players.deleteTeam, async ({ id }) => {
await playersStore!.deleteTeam(id);
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
return { ok: true };
}
await playersStore.deleteTeam(id);
emitPlayersState();
return { ok: true };
});
registerHandler(ipcChannels.players.setTeamsOrder, async ({ teamIds }) => {
const teams = await playersStore!.setTeamsOrder(teamIds);
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
return { teams: [] };
}
const teams = await playersStore.setTeamsOrder(teamIds);
emitPlayersState();
return { teams };
});
registerHandler(ipcChannels.players.assignTeam, async ({ playerId, teamId }) => {
const player = await playersStore!.assignPlayerTeam(playerId, teamId);
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
throw new Error('Players library is disabled in this build');
}
const player = await playersStore.assignPlayerTeam(playerId, teamId);
emitPlayersState();
return { player };
});
registerHandler(ipcChannels.players.pickImage, async () => {
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
return { canceled: true as const };
}
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
@@ -1432,7 +1464,10 @@ async function main() {
return { canceled: false as const, filePath, previewDataUrl };
});
registerHandler(ipcChannels.players.imageUrl, ({ id }) => {
return { url: playersStore!.getImageUrl(id) };
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
return { url: null };
}
return { url: playersStore.getImageUrl(id) };
});
registerHandler(ipcChannels.sceneNpcTokensSession.getState, () => {
return { state: sceneNpcTokensSessionStore.getState() };
+2 -2
View File
@@ -81,7 +81,7 @@ async function serveFile(info: ReadInfo, request: Request): Promise<Response> {
export function registerDndAssetProtocol(
projectStore: ZipProjectStore,
tokensStore: TokensStore,
playersStore: PlayersStore,
playersStore: PlayersStore | null,
): void {
session.defaultSession.protocol.handle('dnd', async (request) => {
const url = new URL(request.url);
@@ -95,7 +95,7 @@ export function registerDndAssetProtocol(
} else if (url.hostname === 'token') {
info = tokensStore.getImageReadInfo(asTokenId(id));
} else if (url.hostname === 'player') {
info = playersStore.getImageReadInfo(asPlayerId(id));
info = playersStore?.getImageReadInfo(asPlayerId(id)) ?? null;
}
if (!info) {
return new Response(null, { status: 404 });
+8 -3
View File
@@ -5,6 +5,7 @@ import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
import { fitAspectRect } from '../../shared/geometry/fitAspectRect';
import { ipcChannels } from '../../shared/ipc/contracts';
import type { SessionState } from '../../shared/ipc/contracts';
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
import {
isNodeInMainStoryline,
isNodeInSideStoryline,
@@ -1778,6 +1779,8 @@ export function ControlApp() {
<div className={styles.previewHeader}>
<div className={styles.previewTitle}>{t('control.screenPreview')}</div>
<div className={styles.previewActions}>
{USERS_BRANCH_FEATURES_ENABLED ? (
<>
<label className={styles.npcTokenScale}>
<span className={styles.npcTokenScaleLabel}>{t('control.npcTokenScale')}</span>
<input
@@ -1816,6 +1819,8 @@ export function ControlApp() {
? t('control.hidePlayers')
: t('control.showPlayers')}
</Button>
) : null}
</>
) : null}
<Button onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}>
{t('control.stopPresentation')}
@@ -2026,7 +2031,7 @@ export function ControlApp() {
}}
/>
) : null}
{previewContentRect ? (
{USERS_BRANCH_FEATURES_ENABLED && previewContentRect ? (
<SceneNpcTokensOverlay
placements={currentScene?.npcTokens ?? []}
library={session?.project?.npcs ?? []}
@@ -2046,7 +2051,7 @@ export function ControlApp() {
}}
/>
) : null}
{previewContentRect ? (
{USERS_BRANCH_FEATURES_ENABLED && previewContentRect ? (
<ScenePlayerTokensOverlay
library={appPlayers}
session={scenePlayerTokensSession}
@@ -2509,7 +2514,7 @@ export function ControlApp() {
) : null}
</div>
{npcSessionCtxMenu
{USERS_BRANCH_FEATURES_ENABLED && npcSessionCtxMenu
? createPortal(
<>
<button
+22 -1
View File
@@ -2,6 +2,7 @@ import React, { startTransition, useCallback, useEffect, useMemo, useRef, useSta
import { createPortal } from 'react-dom';
import { moveSceneInListOrder, reconcileSceneListOrder } from '../../shared/graph/sceneListOrder';
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
import type {
NpcImportResolution,
SceneImportResolution,
@@ -841,6 +842,7 @@ export function EditorApp() {
{t('top.file')}
</button>
) : null}
{USERS_BRANCH_FEATURES_ENABLED ? (
<button
type="button"
data-testid="players-header-btn"
@@ -858,6 +860,7 @@ export function EditorApp() {
>
{t('top.players')}
</button>
) : null}
</div>
<div className={styles.flex1} />
{appVersionText ? (
@@ -868,6 +871,7 @@ export function EditorApp() {
<div className={styles.headerActions}>
{state.project ? (
<>
{USERS_BRANCH_FEATURES_ENABLED ? (
<div
ref={runSplitRef}
className={styles.splitRun}
@@ -917,6 +921,19 @@ export function EditorApp() {
</svg>
</button>
</div>
) : (
<Button
variant="primary"
disabled={runDisabled || launching}
data-testid="run-main-btn"
onClick={() => {
if (!licenseActive || !graphStartGraphNodeId || launching) return;
launchFromGraphNode(graphStartGraphNodeId);
}}
>
{t('top.run')}
</Button>
)}
{runDisabled ? (
<Button
variant="ghost"
@@ -1620,6 +1637,8 @@ export function EditorApp() {
}}
/>
<CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} />
{USERS_BRANCH_FEATURES_ENABLED ? (
<>
<PlayersManagerModal open={playersManagerOpen} onClose={() => setPlayersManagerOpen(false)} />
<LaunchPlayersModal
open={launchPlayersOpen}
@@ -1629,7 +1648,9 @@ export function EditorApp() {
launchFromGraphNode(graphStartGraphNodeId, playerIds);
}}
/>
{runMenuOpen && runMenuPos
</>
) : null}
{USERS_BRANCH_FEATURES_ENABLED && runMenuOpen && runMenuPos
? createPortal(
<div
data-runmenu-root="1"
+8 -2
View File
@@ -1,5 +1,7 @@
/** Порядок разделов в окне «Инструкция». */
export const HELP_SECTION_IDS = [
import { USERS_BRANCH_FEATURES_ENABLED } from '../../../shared/features/usersBranchFeatures';
const HELP_SECTION_IDS_ALL = [
'overview',
'license',
'projects',
@@ -25,7 +27,11 @@ export const HELP_SECTION_IDS = [
'settings',
] as const;
export type HelpSectionId = (typeof HELP_SECTION_IDS)[number];
export type HelpSectionId = (typeof HELP_SECTION_IDS_ALL)[number];
export const HELP_SECTION_IDS: readonly HelpSectionId[] = USERS_BRANCH_FEATURES_ENABLED
? HELP_SECTION_IDS_ALL
: HELP_SECTION_IDS_ALL.filter((id) => id !== 'players');
export function helpSectionTitleKey(id: HelpSectionId): string {
return `help.section.${id}.title`;
+11
View File
@@ -14,6 +14,7 @@ import type {
import editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
import { Button, Input, Select } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
@@ -737,6 +738,7 @@ export function NpcsEditorApp() {
<div>
<div className={styles.fieldLabel}>{t('npcs.avatar')}</div>
<div className={styles.avatarPick}>
{USERS_BRANCH_FEATURES_ENABLED ? (
<PlayerTokenView
name={selected.name}
imageUrl={selectedUrl}
@@ -758,6 +760,13 @@ export function NpcsEditorApp() {
});
}}
/>
) : (
<div className={styles.avatarPreview}>
{selectedUrl ? (
<img className={styles.avatarPreviewImg} src={selectedUrl} alt="" />
) : null}
</div>
)}
<Button
disabled={avatarBusy}
onClick={() => {
@@ -782,6 +791,7 @@ export function NpcsEditorApp() {
</div>
</div>
{USERS_BRANCH_FEATURES_ENABLED ? (
<label>
<div className={styles.fieldLabel}>{t('npcs.disposition')}</div>
<Select
@@ -800,6 +810,7 @@ export function NpcsEditorApp() {
]}
/>
</label>
) : null}
<div>
<div className={styles.fieldLabel}>{t('npcs.name')}</div>
+6 -3
View File
@@ -44,6 +44,7 @@ import {
trapTypeLabelRu,
} from '../../shared/types/sceneTraps';
import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
import editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
@@ -484,7 +485,7 @@ export function SceneEditorApp() {
return;
}
const npcId = e.dataTransfer.getData(SCENE_NPC_DND_MIME);
if (npcId) {
if (USERS_BRANCH_FEATURES_ENABLED && npcId) {
addNpcTokenAt(npcId as NpcId, p.x, p.y);
return;
}
@@ -600,6 +601,7 @@ export function SceneEditorApp() {
) : null}
</div>
{USERS_BRANCH_FEATURES_ENABLED ? (
<div className={styles.accordion} data-testid="scene-npc-accordion">
<button type="button" className={styles.accordionHead} onClick={() => setNpcsOpen((v) => !v)}>
НПС {npcsOpen ? '▾' : '▸'}
@@ -621,6 +623,7 @@ export function SceneEditorApp() {
</div>
) : null}
</div>
) : null}
<div className={styles.accordion}>
<button type="button" className={styles.accordionHead} onClick={() => setTokensOpen((v) => !v)}>
@@ -884,7 +887,7 @@ export function SceneEditorApp() {
);
})
: null}
{contentRect
{USERS_BRANCH_FEATURES_ENABLED && contentRect
? localNpcTokens.map((tok) => {
const npc = project?.npcs.find((item) => item.id === tok.npcId);
if (!npc) return null;
@@ -1050,7 +1053,7 @@ export function SceneEditorApp() {
)
: null}
{npcCtxMenu
{USERS_BRANCH_FEATURES_ENABLED && npcCtxMenu
? createPortal(
<>
<button
+5 -3
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
import type { SessionState } from '../../shared/ipc/contracts';
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
import { ExplosionVideoOverlay } from './effects/ExplosionVideoOverlay';
import { PixiEffectsOverlay } from './effects/PxiEffectsOverlay';
@@ -54,7 +55,8 @@ export function PresentationView({
const [materialsOverlay] = useMaterialsOverlayState();
const [npcsOverlay] = useNpcsOverlayState();
const appTokens = useAppTokens();
const { players: appPlayers } = useAppPlayers();
const appPlayersResult = useAppPlayers();
const appPlayers = USERS_BRANCH_FEATURES_ENABLED ? appPlayersResult.players : [];
const [sceneTokensSession] = useSceneTokensSession();
const [sceneNpcTokensSession] = useSceneNpcTokensSession();
const [scenePlayerTokensSession] = useScenePlayerTokensSession();
@@ -196,7 +198,7 @@ export function PresentationView({
viewport={contentRect}
/>
) : null}
{scene?.previewAssetType === 'image' && contentRect ? (
{USERS_BRANCH_FEATURES_ENABLED && scene?.previewAssetType === 'image' && contentRect ? (
<SceneNpcTokensOverlay
placements={scene.npcTokens ?? []}
library={project?.npcs ?? []}
@@ -205,7 +207,7 @@ export function PresentationView({
grid={scene.grid}
/>
) : null}
{scene?.previewAssetType === 'image' && contentRect ? (
{USERS_BRANCH_FEATURES_ENABLED && scene?.previewAssetType === 'image' && contentRect ? (
<ScenePlayerTokensOverlay
library={appPlayers}
session={scenePlayerTokensSession}
@@ -0,0 +1,6 @@
/**
* Hotfix release: set to `false` to hide everything merged from branch `users`
* (Players library, run-with-players, circular NPC/player session tokens, disposition UI).
* Re-enable after QA.
*/
export const USERS_BRANCH_FEATURES_ENABLED = false;