diff --git a/.env.example b/.env.example index 1bf29ff..ab530db 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,11 @@ SCOREKO_LOADING_ROUTE=dashboard/loading/main.html?standalone=true ELECTRON_LOAD_DELAY_MS=10000 NODECG_STARTUP_TIMEOUT_MS=30000 NODECG_KILL_TIMEOUT_MS=2500 + +# Updates +SCOREKO_UPDATES_ENABLED=true +# SCOREKO_UPDATE_API_URL=http://gitea.local/api/v1/repos/OWNER/REPO/releases/latest +# SCOREKO_UPDATE_RELEASE_PAGE_URL=http://gitea.local/OWNER/REPO/releases +SCOREKO_UPDATE_ASSET_PATTERN=Scoreko-setup-.*\.exe$ +SCOREKO_UPDATE_CHECK_DELAY_MS=5000 +# SCOREKO_UPDATE_CONFIG_PATH=static/updates.json diff --git a/README.md b/README.md index f2f9f86..715bd59 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,21 @@ On first launch, Scoreko copies the packaged NodeCG runtime to the user's app da - `npm run dist:win`: create the Windows installer. - `npm run doctor`: check the prepared runtime and the configured port. +## Updates from Gitea + +Scoreko can check a Gitea release feed without forcing the user to update. Edit `static/updates.json` before building: + +```json +{ + "enabled": true, + "apiUrl": "http://gitea.local/api/v1/repos/OWNER/REPO/releases/latest", + "releasePageUrl": "http://gitea.local/OWNER/REPO/releases", + "assetPattern": "Scoreko-setup-.*\\.exe$" +} +``` + +For each release, bump `package.json` version, build with `npm run dist:win`, create a Gitea release tagged like `v0.2.0`, and attach `release/Scoreko-setup-0.2.0.exe`. When Scoreko sees a newer tag, it asks whether to download and install it. + ## Configuration The defaults match the parent bundle: @@ -47,5 +62,7 @@ The defaults match the parent bundle: - `NODECG_PORT=9090` - `SCOREKO_DASHBOARD_ROUTE=dashboard/scoreko-dev/main.html?standalone=true` - `SCOREKO_LOADING_ROUTE=dashboard/loading/main.html?standalone=true` +- `SCOREKO_UPDATES_ENABLED=true` +- `SCOREKO_UPDATE_ASSET_PATTERN=Scoreko-setup-.*\.exe$` Copy `.env.example` only if you need local overrides while developing. diff --git a/docs/architecture.md b/docs/architecture.md index 5b93edc..8f5edf0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,13 +8,16 @@ 4. In packaged builds, relaunches once after a fresh runtime install so NodeCG starts from a settled user-data runtime. 5. Starts NodeCG with `nodecg/process-manager.ts`. 6. Waits for HTTP readiness and shows loading -> main dashboard. -7. On shutdown, runs a single graceful-stop flow to avoid orphan processes. +7. Checks the configured Gitea latest-release endpoint for optional updates. +8. On shutdown, runs a single graceful-stop flow to avoid orphan processes. ## Main modules - `config/runtime-config.ts`: read/validate env vars. - `nodecg/runtime-provisioner.ts`: install/refresh the managed runtime in the writable user data folder and report whether it changed. - `nodecg/process-manager.ts`: start, readiness, and stop for NodeCG; install/permission/port validation. +- `updates/update-manager.ts`: optional Gitea release checks, installer download, and user-controlled install. +- `updates/update-utils.ts`: release version comparison and installer asset selection. - `windows/window-factory.ts`: window creation and navigation policy. - `windows/navigation-security.ts`: internal navigation allowlist and safe external schemes. - `errors/error-presenter.ts`: fatal error presentation. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 440d232..a2cb290 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -36,3 +36,10 @@ - The configuration expects `static/icons/icon.icns`. - Create that file before running macOS packaging. + +## Updates do not appear + +- Check that `static/updates.json` has `"enabled": true` before building the installer. +- The `apiUrl` must point to Gitea's latest release API: `/api/v1/repos///releases/latest`. +- The release tag must be newer than the installed `package.json` version, for example `v0.2.0`. +- The release must include an installer asset matching `assetPattern`, by default `Scoreko-setup-.*\.exe$`. diff --git a/src/main/config/runtime-config.ts b/src/main/config/runtime-config.ts index ca80ff3..05ce75f 100644 --- a/src/main/config/runtime-config.ts +++ b/src/main/config/runtime-config.ts @@ -10,6 +10,12 @@ export type AppRuntimeConfig = { loadDelayMs: number; startupTimeoutMs: number; nodecgKillTimeoutMs: number; + updatesEnabled: boolean; + updateApiUrl?: string; + updateReleasePageUrl?: string; + updateAssetPattern?: string; + updateConfigPathOverride?: string; + updateCheckDelayMs: number; }; const MIN_TCP_PORT = 1; @@ -29,6 +35,12 @@ export function getRuntimeConfig(): AppRuntimeConfig { loadDelayMs: parseEnvIntInRange("ELECTRON_LOAD_DELAY_MS", 10000, 0, 600000), startupTimeoutMs: parseEnvIntInRange("NODECG_STARTUP_TIMEOUT_MS", 30000, 1000, 600000), nodecgKillTimeoutMs: parseEnvIntInRange("NODECG_KILL_TIMEOUT_MS", 2500, 0, 120000), + updatesEnabled: parseEnvBool("SCOREKO_UPDATES_ENABLED", true), + updateApiUrl: parseOptionalHttpUrl("SCOREKO_UPDATE_API_URL"), + updateReleasePageUrl: parseOptionalHttpUrl("SCOREKO_UPDATE_RELEASE_PAGE_URL"), + updateAssetPattern: getOptionalEnv("SCOREKO_UPDATE_ASSET_PATTERN"), + updateConfigPathOverride: getOptionalEnv("SCOREKO_UPDATE_CONFIG_PATH"), + updateCheckDelayMs: parseEnvIntInRange("SCOREKO_UPDATE_CHECK_DELAY_MS", 5000, 0, 600000), }; } @@ -60,12 +72,49 @@ export function parseEnvIntInRange(name: string, fallback: number, min: number, const parsedValue = Number.parseInt(rawValue, 10); if (!Number.isFinite(parsedValue) || parsedValue < min || parsedValue > max) { - throw new Error(`The ${name} variable must be an integer between ${min} and ${max}. Received value: '${rawValue}'.`); + throw new Error( + `The ${name} variable must be an integer between ${min} and ${max}. Received value: '${rawValue}'.`, + ); } return parsedValue; } +export function parseEnvBool(name: string, fallback: boolean): boolean { + const rawValue = process.env[name]?.trim().toLowerCase(); + if (!rawValue) { + return fallback; + } + + if (["1", "true", "yes", "on"].includes(rawValue)) { + return true; + } + + if (["0", "false", "no", "off"].includes(rawValue)) { + return false; + } + + throw new Error(`The ${name} variable must be a boolean. Received value: '${process.env[name]}'.`); +} + +export function parseOptionalHttpUrl(name: string): string | undefined { + const rawValue = getOptionalEnv(name); + if (!rawValue) { + return undefined; + } + + try { + const url = new URL(rawValue); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("unsupported protocol"); + } + + return url.toString(); + } catch { + throw new Error(`The ${name} variable must be a valid HTTP(S) URL. Received value: '${rawValue}'.`); + } +} + export function parseEnvPort(name: string, fallback: string): string { const rawValue = getEnv(name, fallback); const parsedValue = Number.parseInt(rawValue, 10); diff --git a/src/main/main.ts b/src/main/main.ts index 11da455..08b79ef 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -5,6 +5,7 @@ import { getRuntimeConfig } from "./config/runtime-config"; import { showFatalError, log } from "./errors/error-presenter"; import { createNodecgProcessManager, NodecgProcessManager } from "./nodecg/process-manager"; import { prepareUserNodecgRuntime } from "./nodecg/runtime-provisioner"; +import { scheduleUpdateCheck } from "./updates/update-manager"; import { getRemainingDelayMs } from "./utils/timing"; import { createLoadingWindow, createMainWindow } from "./windows/window-factory"; @@ -102,6 +103,13 @@ async function launchApplication(): Promise { mainWindow.show(); closeLoadingWindow(); + scheduleUpdateCheck({ + appConfig, + rootPath, + getParentWindow: () => mainWindow, + beforeInstall: stopNodecgGracefully, + log, + }); } async function startNodecg(): Promise { diff --git a/src/main/updates/update-manager.ts b/src/main/updates/update-manager.ts new file mode 100644 index 0000000..f10cce1 --- /dev/null +++ b/src/main/updates/update-manager.ts @@ -0,0 +1,213 @@ +import { app, BrowserWindow, dialog, shell } from "electron"; +import type { MessageBoxOptions } from "electron"; +import fs from "node:fs"; +import path from "node:path"; +import { Readable } from "node:stream"; + +import { AppRuntimeConfig } from "../config/runtime-config"; +import { buildReleaseUpdate, GiteaRelease, ReleaseUpdate, sanitizeFileName, UpdateFileConfig } from "./update-utils"; + +type UpdateManagerConfig = { + appConfig: AppRuntimeConfig; + rootPath: string; + getParentWindow: () => BrowserWindow | null; + beforeInstall: () => Promise; + log: (...args: unknown[]) => void; +}; + +type UpdateSettings = { + enabled: boolean; + apiUrl?: string; + releasePageUrl?: string; + assetPattern: string; +}; + +export function scheduleUpdateCheck({ + appConfig, + rootPath, + getParentWindow, + beforeInstall, + log, +}: UpdateManagerConfig): void { + const settings = loadUpdateSettings(appConfig, rootPath, log); + + if (!settings.enabled || !settings.apiUrl) { + log("Update checks disabled or not configured."); + return; + } + + setTimeout(() => { + void checkForUpdates({ settings, getParentWindow, beforeInstall, log }); + }, appConfig.updateCheckDelayMs); +} + +async function checkForUpdates({ + settings, + getParentWindow, + beforeInstall, + log, +}: { + settings: UpdateSettings; + getParentWindow: () => BrowserWindow | null; + beforeInstall: () => Promise; + log: (...args: unknown[]) => void; +}): Promise { + try { + if (!settings.apiUrl) { + return; + } + + const release = await fetchLatestRelease(settings.apiUrl); + const update = buildReleaseUpdate(release, app.getVersion(), settings.assetPattern); + + if (!update) { + log("No Scoreko update available."); + return; + } + + const shouldDownload = await askToDownloadUpdate( + update, + settings.releasePageUrl ?? update.pageUrl, + getParentWindow(), + ); + if (!shouldDownload) { + return; + } + + const installerPath = await downloadInstaller(update); + const shouldInstall = await askToInstallUpdate(update, getParentWindow()); + if (!shouldInstall) { + await shell.showItemInFolder(installerPath); + return; + } + + await beforeInstall(); + const openError = await shell.openPath(installerPath); + if (openError) { + throw new Error(openError); + } + + app.exit(0); + } catch (error) { + log("Update check failed.", error); + } +} + +function loadUpdateSettings( + appConfig: AppRuntimeConfig, + rootPath: string, + log: (...args: unknown[]) => void, +): UpdateSettings { + const fileConfig = readUpdateFileConfig(appConfig, rootPath, log); + + return { + enabled: appConfig.updatesEnabled && (Boolean(fileConfig.enabled) || Boolean(appConfig.updateApiUrl)), + apiUrl: appConfig.updateApiUrl ?? readOptionalString(fileConfig.apiUrl), + releasePageUrl: appConfig.updateReleasePageUrl ?? readOptionalString(fileConfig.releasePageUrl), + assetPattern: + appConfig.updateAssetPattern || readOptionalString(fileConfig.assetPattern) || "Scoreko-setup-.*\\.exe$", + }; +} + +function readUpdateFileConfig( + appConfig: AppRuntimeConfig, + rootPath: string, + log: (...args: unknown[]) => void, +): UpdateFileConfig { + const configPath = appConfig.updateConfigPathOverride ?? path.join(rootPath, "static", "updates.json"); + + if (!fs.existsSync(configPath)) { + return {}; + } + + try { + return JSON.parse(fs.readFileSync(configPath, "utf8")) as UpdateFileConfig; + } catch (error) { + log(`Could not read update config at ${configPath}.`, error); + return {}; + } +} + +async function fetchLatestRelease(apiUrl: string): Promise { + const response = await fetch(apiUrl, { + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`Gitea update check failed with HTTP ${response.status}.`); + } + + return (await response.json()) as GiteaRelease; +} + +async function askToDownloadUpdate( + update: ReleaseUpdate, + releasePageUrl: string | undefined, + parentWindow: BrowserWindow | null, +): Promise { + const result = await showMessageBox(parentWindow, { + type: "info", + title: "Actualización disponible", + message: `Scoreko ${update.version} está disponible.`, + detail: "Puedes descargarla ahora o seguir usando esta versión.", + buttons: releasePageUrl ? ["Descargar", "Ver release", "Ahora no"] : ["Descargar", "Ahora no"], + defaultId: 0, + cancelId: releasePageUrl ? 2 : 1, + }); + + if (releasePageUrl && result.response === 1) { + await shell.openExternal(releasePageUrl); + return false; + } + + return result.response === 0; +} + +async function askToInstallUpdate(update: ReleaseUpdate, parentWindow: BrowserWindow | null): Promise { + const result = await showMessageBox(parentWindow, { + type: "question", + title: "Actualización descargada", + message: `Scoreko ${update.version} se ha descargado.`, + detail: "Para instalarla se cerrará Scoreko y se abrirá el instalador.", + buttons: ["Instalar y cerrar", "Luego"], + defaultId: 0, + cancelId: 1, + }); + + return result.response === 0; +} + +async function downloadInstaller(update: ReleaseUpdate): Promise { + const safeFileName = sanitizeFileName(update.installer.name); + const downloadDirectory = path.join(app.getPath("temp"), "scoreko-updates"); + const targetPath = path.join(downloadDirectory, safeFileName); + + fs.mkdirSync(downloadDirectory, { recursive: true }); + + const response = await fetch(update.installer.downloadUrl); + if (!response.ok || !response.body) { + throw new Error(`Could not download update installer. HTTP ${response.status}.`); + } + + await new Promise((resolve, reject) => { + const fileStream = fs.createWriteStream(targetPath); + const responseStream = Readable.fromWeb(response.body as Parameters[0]); + + responseStream.on("error", reject); + fileStream.on("error", reject); + fileStream.on("finish", resolve); + responseStream.pipe(fileStream); + }); + + return targetPath; +} + +function readOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function showMessageBox(parentWindow: BrowserWindow | null, options: MessageBoxOptions) { + return parentWindow ? dialog.showMessageBox(parentWindow, options) : dialog.showMessageBox(options); +} diff --git a/src/main/updates/update-utils.ts b/src/main/updates/update-utils.ts new file mode 100644 index 0000000..71c7d83 --- /dev/null +++ b/src/main/updates/update-utils.ts @@ -0,0 +1,123 @@ +export type GiteaReleaseAsset = { + name?: unknown; + browser_download_url?: unknown; + size?: unknown; +}; + +export type GiteaRelease = { + tag_name?: unknown; + name?: unknown; + html_url?: unknown; + assets?: unknown; +}; + +export type InstallerAsset = { + name: string; + downloadUrl: string; + size?: number; +}; + +export type ReleaseUpdate = { + version: string; + title: string; + pageUrl?: string; + installer: InstallerAsset; +}; + +export type UpdateFileConfig = { + enabled?: unknown; + apiUrl?: unknown; + releasePageUrl?: unknown; + assetPattern?: unknown; +}; + +export function isVersionNewer(candidateVersion: string, currentVersion: string): boolean { + const candidate = normalizeVersion(candidateVersion); + const current = normalizeVersion(currentVersion); + + for (let index = 0; index < Math.max(candidate.length, current.length); index += 1) { + const candidatePart = candidate[index] ?? 0; + const currentPart = current[index] ?? 0; + + if (candidatePart > currentPart) { + return true; + } + + if (candidatePart < currentPart) { + return false; + } + } + + return false; +} + +export function getReleaseVersion(release: GiteaRelease): string | null { + const tagName = typeof release.tag_name === "string" ? release.tag_name.trim() : ""; + return tagName.length > 0 ? tagName.replace(/^v/i, "") : null; +} + +export function getReleaseTitle(release: GiteaRelease, version: string): string { + const releaseName = typeof release.name === "string" ? release.name.trim() : ""; + return releaseName.length > 0 ? releaseName : `Scoreko ${version}`; +} + +export function selectInstallerAsset(release: GiteaRelease, assetPattern: string): InstallerAsset | null { + const assets = Array.isArray(release.assets) ? release.assets : []; + const matcher = new RegExp(assetPattern, "i"); + + for (const asset of assets as GiteaReleaseAsset[]) { + const name = typeof asset.name === "string" ? asset.name : ""; + const downloadUrl = typeof asset.browser_download_url === "string" ? asset.browser_download_url : ""; + + if (!name || !downloadUrl || !matcher.test(name)) { + continue; + } + + return { + name, + downloadUrl, + ...(typeof asset.size === "number" ? { size: asset.size } : {}), + }; + } + + return null; +} + +export function buildReleaseUpdate( + release: GiteaRelease, + currentVersion: string, + assetPattern: string, +): ReleaseUpdate | null { + const version = getReleaseVersion(release); + if (!version || !isVersionNewer(version, currentVersion)) { + return null; + } + + const installer = selectInstallerAsset(release, assetPattern); + if (!installer) { + return null; + } + + const pageUrl = typeof release.html_url === "string" && release.html_url.length > 0 ? release.html_url : undefined; + + return { + version, + title: getReleaseTitle(release, version), + pageUrl, + installer, + }; +} + +export function sanitizeFileName(fileName: string): string { + return fileName.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_"); +} + +function normalizeVersion(version: string): number[] { + return version + .trim() + .replace(/^v/i, "") + .split(/[+-]/)[0] + .split(".") + .map((part) => Number.parseInt(part, 10)) + .map((part) => (Number.isFinite(part) ? part : 0)); +} diff --git a/src/tests/icon-path.test.ts b/src/tests/icon-path.test.ts index dc7a18c..dc6e8a9 100644 --- a/src/tests/icon-path.test.ts +++ b/src/tests/icon-path.test.ts @@ -17,6 +17,9 @@ function getBaseConfig(): AppRuntimeConfig { loadDelayMs: 10000, startupTimeoutMs: 30000, nodecgKillTimeoutMs: 2500, + updatesEnabled: true, + updateAssetPattern: "Scoreko-setup-.*\\.exe$", + updateCheckDelayMs: 5000, }; } diff --git a/src/tests/process-manager.test.ts b/src/tests/process-manager.test.ts index f5d6653..7aa0157 100644 --- a/src/tests/process-manager.test.ts +++ b/src/tests/process-manager.test.ts @@ -30,6 +30,9 @@ function getBaseConfig(): AppRuntimeConfig { loadDelayMs: 10000, startupTimeoutMs: 100, nodecgKillTimeoutMs: 10, + updatesEnabled: true, + updateAssetPattern: "Scoreko-setup-.*\\.exe$", + updateCheckDelayMs: 5000, }; } diff --git a/src/tests/runtime-config.test.ts b/src/tests/runtime-config.test.ts index aab385d..87fa6ff 100644 --- a/src/tests/runtime-config.test.ts +++ b/src/tests/runtime-config.test.ts @@ -1,7 +1,15 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { getEnv, getOptionalEnv, parseEnvInt, parseEnvIntInRange, parseEnvPort } from "../main/config/runtime-config"; +import { + getEnv, + getOptionalEnv, + parseEnvBool, + parseEnvInt, + parseEnvIntInRange, + parseEnvPort, + parseOptionalHttpUrl, +} from "../main/config/runtime-config"; function withEnv(name: string, value: string | undefined, run: () => void): void { const previousValue = process.env[name]; @@ -83,3 +91,31 @@ test("parseEnvPort normalizes valid port", () => { assert.equal(parseEnvPort("TEST_ENV_PORT", "9090"), "9090"); }); }); + +test("parseEnvBool accepts common true and false values", () => { + withEnv("TEST_ENV_BOOL", "yes", () => { + assert.equal(parseEnvBool("TEST_ENV_BOOL", false), true); + }); + + withEnv("TEST_ENV_BOOL", "off", () => { + assert.equal(parseEnvBool("TEST_ENV_BOOL", true), false); + }); +}); + +test("parseEnvBool rejects invalid values", () => { + withEnv("TEST_ENV_BOOL", "maybe", () => { + assert.throws(() => parseEnvBool("TEST_ENV_BOOL", true), /must be a boolean/); + }); +}); + +test("parseOptionalHttpUrl accepts HTTP and HTTPS urls", () => { + withEnv("TEST_UPDATE_URL", "http://gitea.local/api/v1/repos/owner/repo/releases/latest", () => { + assert.equal(parseOptionalHttpUrl("TEST_UPDATE_URL"), "http://gitea.local/api/v1/repos/owner/repo/releases/latest"); + }); +}); + +test("parseOptionalHttpUrl rejects unsupported protocols", () => { + withEnv("TEST_UPDATE_URL", "file:///tmp/latest", () => { + assert.throws(() => parseOptionalHttpUrl("TEST_UPDATE_URL"), /valid HTTP\(S\) URL/); + }); +}); diff --git a/src/tests/update-utils.test.ts b/src/tests/update-utils.test.ts new file mode 100644 index 0000000..308d560 --- /dev/null +++ b/src/tests/update-utils.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildReleaseUpdate, + isVersionNewer, + sanitizeFileName, + selectInstallerAsset, +} from "../main/updates/update-utils"; + +test("isVersionNewer compares semantic versions with optional v prefix", () => { + assert.equal(isVersionNewer("v0.2.0", "0.1.9"), true); + assert.equal(isVersionNewer("0.1.0", "0.1.0"), false); + assert.equal(isVersionNewer("0.1.0", "0.2.0"), false); +}); + +test("selectInstallerAsset picks the first matching exe asset", () => { + const asset = selectInstallerAsset( + { + assets: [ + { name: "latest.yml", browser_download_url: "http://gitea/latest.yml" }, + { name: "Scoreko-setup-0.2.0.exe", browser_download_url: "http://gitea/Scoreko-setup-0.2.0.exe", size: 100 }, + ], + }, + "Scoreko-setup-.*\\.exe$", + ); + + assert.deepEqual(asset, { + name: "Scoreko-setup-0.2.0.exe", + downloadUrl: "http://gitea/Scoreko-setup-0.2.0.exe", + size: 100, + }); +}); + +test("buildReleaseUpdate returns null when the release is not newer", () => { + const update = buildReleaseUpdate( + { + tag_name: "v0.1.0", + assets: [{ name: "Scoreko-setup-0.1.0.exe", browser_download_url: "http://gitea/Scoreko-setup-0.1.0.exe" }], + }, + "0.1.0", + "Scoreko-setup-.*\\.exe$", + ); + + assert.equal(update, null); +}); + +test("buildReleaseUpdate builds update info for newer releases", () => { + const update = buildReleaseUpdate( + { + tag_name: "v0.2.0", + name: "Scoreko 0.2.0", + html_url: "http://gitea/releases/v0.2.0", + assets: [{ name: "Scoreko-setup-0.2.0.exe", browser_download_url: "http://gitea/Scoreko-setup-0.2.0.exe" }], + }, + "0.1.0", + "Scoreko-setup-.*\\.exe$", + ); + + assert.equal(update?.version, "0.2.0"); + assert.equal(update?.title, "Scoreko 0.2.0"); + assert.equal(update?.pageUrl, "http://gitea/releases/v0.2.0"); + assert.equal(update?.installer.name, "Scoreko-setup-0.2.0.exe"); +}); + +test("sanitizeFileName removes Windows-unsafe characters", () => { + assert.equal(sanitizeFileName('Scoreko:setup*"0.2.0.exe'), "Scoreko_setup__0.2.0.exe"); +}); diff --git a/static/updates.json b/static/updates.json new file mode 100644 index 0000000..c7bc959 --- /dev/null +++ b/static/updates.json @@ -0,0 +1,6 @@ +{ + "enabled": false, + "apiUrl": "http://gitea.local/api/v1/repos/OWNER/REPO/releases/latest", + "releasePageUrl": "http://gitea.local/OWNER/REPO/releases", + "assetPattern": "Scoreko-setup-.*\\.exe$" +}