diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index ba98b1d..5c908d2 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -67,6 +67,9 @@ jobs: - run: npm ci + - name: sharp (@img/sharp-win32-x64) для Windows-артефакта при сборке на Linux + run: npm install --no-save @img/sharp-win32-x64@0.34.5 + - run: npm run build - name: electron-builder (win) diff --git a/app/main/index.ts b/app/main/index.ts index 068c5de..53677f3 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -252,6 +252,7 @@ async function main() { registerHandler(ipcChannels.app.getVersion, () => ({ version: getAppSemanticVersion(), buildNumber: getOptionalBuildNumber(), + packaged: app.isPackaged, })); registerHandler(ipcChannels.license.getStatus, () => licenseService.getStatus()); registerHandler(ipcChannels.license.setToken, async ({ token }) => licenseService.setToken(token)); @@ -526,9 +527,9 @@ async function main() { return { ok: true }; }); + installAutoUpdater(licenseService, registerHandler); installIpcRouter(); applyDockIconIfNeeded(); - installAutoUpdater(licenseService); await runStartupAfterHandlers(licenseService); app.on('activate', () => { diff --git a/app/main/ipc/router.ts b/app/main/ipc/router.ts index 8f879da..23c0679 100644 --- a/app/main/ipc/router.ts +++ b/app/main/ipc/router.ts @@ -36,6 +36,8 @@ export function registerHandler(channel: K, handle handlers.set(channelStr, inner); } +export type IpcRegisterHandler = typeof registerHandler; + export function installIpcRouter(): void { for (const [channel, handler] of handlers.entries()) { ipcMain.handle(channel, async (_event, payload: unknown) => handler(payload)); diff --git a/app/main/update/installAutoUpdater.ts b/app/main/update/installAutoUpdater.ts index 8ec1ee6..93c4e2a 100644 --- a/app/main/update/installAutoUpdater.ts +++ b/app/main/update/installAutoUpdater.ts @@ -1,6 +1,12 @@ import { app, dialog } from 'electron'; import { autoUpdater } from 'electron-updater'; +import { + ipcChannels, + type UpdaterCheckResponse, + type UpdaterDownloadResponse, +} from '../../shared/ipc/contracts'; +import type { IpcRegisterHandler } from '../ipc/router'; import { addLicenseChangeListener } from '../license/licenseService'; import type { LicenseService } from '../license/licenseService'; @@ -9,6 +15,10 @@ const STARTUP_CHECK_DELAY_MS = 12_000; const RE_CHECK_COOLDOWN_MS = 30_000; let lastCheckAt = 0; +/** Ручная установка: не показывать второй диалог из `update-downloaded`. */ +let suppressAutoInstallDialog = false; + +type RegisterFn = IpcRegisterHandler; function isLicensedForUpdates(licenseService: LicenseService): boolean { const snap = licenseService.getStatusSync(); @@ -24,11 +34,53 @@ function maybeCheckForUpdates(licenseService: LicenseService, ignoreCooldown: bo void autoUpdater.checkForUpdates().catch(() => undefined); } +async function runManualUpdaterCheck(licenseService: LicenseService): Promise { + if (!app.isPackaged) { + return { outcome: 'not_packaged' }; + } + if (!isLicensedForUpdates(licenseService)) { + return { outcome: 'no_license' }; + } + try { + const result = await autoUpdater.checkForUpdates(); + if (result && result.isUpdateAvailable && result.updateInfo.version) { + return { outcome: 'available', version: result.updateInfo.version }; + } + return { outcome: 'current', currentVersion: app.getVersion() }; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return { outcome: 'error', message }; + } +} + +async function runManualDownloadAndRestart(): Promise { + if (!app.isPackaged) { + return { ok: false, message: 'NOT_PACKAGED' }; + } + try { + suppressAutoInstallDialog = true; + await autoUpdater.downloadUpdate(); + autoUpdater.quitAndInstall(false, true); + return { ok: true }; + } catch (e) { + suppressAutoInstallDialog = false; + const message = e instanceof Error ? e.message : String(e); + return { ok: false, message }; + } +} + +function registerUpdaterHandlers(register: RegisterFn, licenseService: LicenseService): void { + register(ipcChannels.updater.check, () => runManualUpdaterCheck(licenseService)); + register(ipcChannels.updater.downloadAndRestart, () => runManualDownloadAndRestart()); +} + /** * Проверка обновлений: только упакованное приложение, только при активной лицензии. * Канал и URL задаются при сборке (`publish` → `app-update.yml` внутри установки). */ -export function installAutoUpdater(licenseService: LicenseService): void { +export function installAutoUpdater(licenseService: LicenseService, register: RegisterFn): void { + registerUpdaterHandlers(register, licenseService); + if (!app.isPackaged) return; const feedOverride = process.env.DND_UPDATE_FEED_URL?.trim(); @@ -41,6 +93,10 @@ export function installAutoUpdater(licenseService: LicenseService): void { autoUpdater.autoInstallOnAppQuit = true; autoUpdater.on('update-downloaded', (info) => { + if (suppressAutoInstallDialog) { + suppressAutoInstallDialog = false; + return; + } void dialog .showMessageBox({ type: 'info', diff --git a/app/renderer/editor/EditorApp.tsx b/app/renderer/editor/EditorApp.tsx index 3888ca6..0fa96c5 100644 --- a/app/renderer/editor/EditorApp.tsx +++ b/app/renderer/editor/EditorApp.tsx @@ -1,7 +1,7 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { ipcChannels } from '../../shared/ipc/contracts'; +import { ipcChannels, type UpdaterCheckResponse } from '../../shared/ipc/contracts'; import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion'; import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot'; import type { AssetId, MediaAsset, Project, ProjectId, SceneAudioRef, SceneId } from '../../shared/types'; @@ -76,6 +76,8 @@ export function EditorApp() { const [previewBusy, setPreviewBusy] = useState(false); const [presentationOpen, setPresentationOpen] = useState(false); const [licenseSnap, setLicenseSnap] = useState(null); + const [checkUpdatesOpen, setCheckUpdatesOpen] = useState(false); + const [appPackaged, setAppPackaged] = useState(false); const [licenseKeyModalOpen, setLicenseKeyModalOpen] = useState(false); const [eulaModalOpen, setEulaModalOpen] = useState(false); const [aboutLicenseOpen, setAboutLicenseOpen] = useState(false); @@ -305,6 +307,7 @@ export function EditorApp() { const r = await getDndApi().invoke(ipcChannels.app.getVersion, {}); const label = r.buildNumber ? `v${r.version} · ${r.buildNumber}` : `v${r.version}`; setAppVersionText(label); + setAppPackaged(r.packaged); } catch { setAppVersionText(null); } @@ -647,6 +650,20 @@ export function EditorApp() { > {t('menu.aboutLicense')} + {licenseActive && appPackaged ? ( + + ) : null}
+
+
{body}
+
+ {showUpdateIdle ? ( + <> + + + + ) : showUpdateBusy ? ( + + ) : ( + + )} +
+ + , + document.body, + ); +} + type SimpleMessageModalProps = { open: boolean; title?: string; diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts index cba7749..7c5b359 100644 --- a/app/renderer/editor/i18n/editorMessages.ts +++ b/app/renderer/editor/i18n/editorMessages.ts @@ -114,10 +114,21 @@ export const EDITOR_MESSAGES: Record> = { 'menu.enterKey': 'Указать ключ', 'menu.aboutLicense': 'О лицензии', + 'menu.checkUpdates': 'Проверить обновления', 'menu.language': 'Язык', 'menu.langRu': 'Русский', 'menu.langEn': 'English', + 'updates.dialogTitle': 'Обновления', + 'updates.checking': 'Проверка наличия обновлений…', + 'updates.available': 'Доступна новая версия {version}.', + 'updates.current': 'У вас установлена актуальная версия ({version}).', + 'updates.error': 'Не удалось проверить обновления: {message}', + 'updates.notPackaged': 'Проверка доступна только в установленной версии приложения.', + 'updates.noLicense': 'Нужна активная лицензия.', + 'updates.download': 'Обновить', + 'updates.downloading': 'Загрузка…', + 'projectMenu.home': 'Начальный экран', 'projectMenu.import': 'Импорт', 'projectMenu.export': 'Экспорт', @@ -330,10 +341,21 @@ export const EDITOR_MESSAGES: Record> = { 'menu.enterKey': 'Enter license key', 'menu.aboutLicense': 'About license', + 'menu.checkUpdates': 'Check for updates', 'menu.language': 'Language', 'menu.langRu': 'Русский', 'menu.langEn': 'English', + 'updates.dialogTitle': 'Updates', + 'updates.checking': 'Checking for updates…', + 'updates.available': 'A new version is available: {version}.', + 'updates.current': 'You have the latest version ({version}).', + 'updates.error': 'Could not check for updates: {message}', + 'updates.notPackaged': 'Updates can only be checked in the installed application.', + 'updates.noLicense': 'An active license is required.', + 'updates.download': 'Update', + 'updates.downloading': 'Downloading…', + 'projectMenu.home': 'Home', 'projectMenu.import': 'Import', 'projectMenu.export': 'Export', diff --git a/app/shared/ipc/contracts.ts b/app/shared/ipc/contracts.ts index 6ada704..89a1e10 100644 --- a/app/shared/ipc/contracts.ts +++ b/app/shared/ipc/contracts.ts @@ -18,6 +18,10 @@ export const ipcChannels = { quit: 'app.quit', getVersion: 'app.getVersion', }, + updater: { + check: 'updater.check', + downloadAndRestart: 'updater.downloadAndRestart', + }, project: { list: 'project.list', create: 'project.create', @@ -84,6 +88,15 @@ export type ZipProgressEvent = { detail?: string; }; +export type UpdaterCheckResponse = + | { outcome: 'not_packaged' } + | { outcome: 'no_license' } + | { outcome: 'current'; currentVersion: string } + | { outcome: 'available'; version: string } + | { outcome: 'error'; message: string }; + +export type UpdaterDownloadResponse = { ok: true } | { ok: false; message: string }; + export type IpcEventMap = { [ipcChannels.session.stateChanged]: { state: SessionState }; [ipcChannels.effects.stateChanged]: { state: EffectsState }; @@ -101,7 +114,15 @@ export type IpcInvokeMap = { }; [ipcChannels.app.getVersion]: { req: Record; - res: { version: string; buildNumber: string | null }; + res: { version: string; buildNumber: string | null; packaged: boolean }; + }; + [ipcChannels.updater.check]: { + req: Record; + res: UpdaterCheckResponse; + }; + [ipcChannels.updater.downloadAndRestart]: { + req: Record; + res: UpdaterDownloadResponse; }; [ipcChannels.project.list]: { req: Record; diff --git a/package-lock.json b/package-lock.json index 1d882c3..12e6c03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "DndGamePlayer", - "version": "1.0.2", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "DndGamePlayer", - "version": "1.0.2", + "version": "1.0.3", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index a01c264..1dd8c02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DndGamePlayer", - "version": "1.0.2", + "version": "1.0.3", "description": "DNDGamePlayer — редактор и проигрыватель игр", "main": "dist/main/index.cjs", "scripts": {