diff --git a/.gitignore b/.gitignore index 7207ec2..a9a9334 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,12 @@ coverage/ npm-debug.log* yarn-debug.log* yarn-error.log* + +# Forgejo deployment runtime files +deploy/forgejo/.env +deploy/forgejo/forgejo/ +deploy/forgejo/postgres/ +deploy/forgejo/caddy/data/ +deploy/forgejo/caddy/config/ +deploy/forgejo/caddy/logs/ +deploy/forgejo/backups/ diff --git a/CLAUDE.md b/CLAUDE.md index e69de29..db9e22e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -0,0 +1,23 @@ +## Release + Update Source (Wichtig) + +- Primäre Plattform ist `https://git.24-music.de` +- Standard-Repo: `Administrator/real-debrid-downloader` +- Nicht mehr primär über Codeberg/GitHub releasen + +## Releasen + +1. Token setzen: + - PowerShell: `$env:GITEA_TOKEN=""` +2. Release ausführen: + - `npm run release:gitea -- [notes]` + +Das Script: +- bumped `package.json` +- baut Windows-Artefakte +- pusht `main` + Tag +- erstellt Release auf `git.24-music.de` +- lädt Assets hoch + +## Auto-Update + +- Updater nutzt aktuell `git.24-music.de` als Standardquelle diff --git a/README.md b/README.md index b0a76d4..ad85c5e 100644 --- a/README.md +++ b/README.md @@ -65,18 +65,18 @@ Desktop downloader with fast queue management, automatic extraction, and robust - Minimize-to-tray with tray menu controls. - Speed limits globally or per download. - Bandwidth schedules for time-based speed profiles. -- Built-in auto-updater via Codeberg Releases. +- Built-in auto-updater via `git.24-music.de` Releases. - Long path support (>260 characters) on Windows. ## Installation ### Option A: prebuilt releases (recommended) -1. Download a release from the Codeberg Releases page. +1. Download a release from the `git.24-music.de` Releases page. 2. Run the installer or portable build. 3. Add your debrid tokens in Settings. -Releases: `https://codeberg.org/Sucukdeluxe/real-debrid-downloader/releases` +Releases: `https://git.24-music.de/Administrator/real-debrid-downloader/releases` ### Option B: build from source @@ -103,21 +103,34 @@ npm run dev | `npm test` | Runs Vitest unit tests | | `npm run self-check` | Runs integrated end-to-end self-checks | | `npm run release:win` | Creates Windows installer and portable build | -| `npm run release:codeberg -- [notes]` | One-command version bump + build + tag + Codeberg release upload | +| `npm run release:gitea -- [notes]` | One-command version bump + build + tag + release upload to `git.24-music.de` | +| `npm run release:codeberg -- [notes]` | Legacy path for old Codeberg workflow | -### One-command Codeberg release +### One-command git.24-music release ```bash -npm run release:codeberg -- 1.4.42 "- Maintenance update" +npm run release:gitea -- 1.6.31 "- Maintenance update" ``` This command will: 1. Bump `package.json` version. 2. Build setup/portable artifacts (`npm run release:win`). -3. Commit and push `main` to your Codeberg remote. +3. Commit and push `main` to your `git.24-music.de` remote. 4. Create and push tag `v`. -5. Create/update the Codeberg release and upload required assets. +5. Create/update the Gitea release and upload required assets. + +Required once before release: + +```bash +git remote add gitea https://git.24-music.de//.git +``` + +PowerShell token setup: + +```powershell +$env:GITEA_TOKEN="" +``` ## Typical workflow @@ -154,7 +167,7 @@ The app stores runtime files in Electron's `userData` directory, including: ## Changelog -Release history is available on [Codeberg Releases](https://codeberg.org/Sucukdeluxe/real-debrid-downloader/releases). +Release history is available on [git.24-music.de Releases](https://git.24-music.de/Administrator/real-debrid-downloader/releases). ## License diff --git a/package.json b/package.json index 279a2c9..3c98fb7 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "test": "vitest run", "self-check": "tsx tests/self-check.ts", "release:win": "npm run build && electron-builder --publish never --win nsis portable", - "release:codeberg": "node scripts/release_codeberg.mjs" + "release:codeberg": "node scripts/release_codeberg.mjs", + "release:gitea": "node scripts/release_gitea.mjs", + "release:forgejo": "node scripts/release_gitea.mjs" }, "dependencies": { "adm-zip": "^0.5.16", diff --git a/scripts/release_gitea.mjs b/scripts/release_gitea.mjs new file mode 100644 index 0000000..834ea16 --- /dev/null +++ b/scripts/release_gitea.mjs @@ -0,0 +1,321 @@ +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const NPM_EXECUTABLE = process.platform === "win32" ? "npm.cmd" : "npm"; + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: process.cwd(), + encoding: "utf8", + stdio: options.capture ? ["pipe", "pipe", "pipe"] : "inherit" + }); + if (result.status !== 0) { + const stderr = result.stderr ? String(result.stderr).trim() : ""; + const stdout = result.stdout ? String(result.stdout).trim() : ""; + const details = [stderr, stdout].filter(Boolean).join("\n"); + throw new Error(`Command failed: ${command} ${args.join(" ")}${details ? `\n${details}` : ""}`); + } + return options.capture ? String(result.stdout || "") : ""; +} + +function runCapture(command, args) { + const result = spawnSync(command, args, { + cwd: process.cwd(), + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"] + }); + if (result.status !== 0) { + const stderr = String(result.stderr || "").trim(); + throw new Error(stderr || `Command failed: ${command} ${args.join(" ")}`); + } + return String(result.stdout || "").trim(); +} + +function runWithInput(command, args, input) { + const result = spawnSync(command, args, { + cwd: process.cwd(), + encoding: "utf8", + input, + stdio: ["pipe", "pipe", "pipe"] + }); + if (result.status !== 0) { + const stderr = String(result.stderr || "").trim(); + throw new Error(stderr || `Command failed: ${command} ${args.join(" ")}`); + } + return String(result.stdout || ""); +} + +function parseArgs(argv) { + const args = argv.slice(2); + if (args.includes("--help") || args.includes("-h")) { + return { help: true }; + } + + const dryRun = args.includes("--dry-run"); + const cleaned = args.filter((arg) => arg !== "--dry-run"); + const version = cleaned[0] || ""; + const notes = cleaned.slice(1).join(" ").trim(); + return { help: false, dryRun, version, notes }; +} + +function parseRemoteUrl(url) { + const raw = String(url || "").trim(); + const httpsMatch = raw.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/i); + if (httpsMatch) { + return { host: httpsMatch[1], owner: httpsMatch[2], repo: httpsMatch[3] }; + } + const sshMatch = raw.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/i); + if (sshMatch) { + return { host: sshMatch[1], owner: sshMatch[2], repo: sshMatch[3] }; + } + const sshAltMatch = raw.match(/^ssh:\/\/git@([^/:]+)(?::\d+)?\/([^/]+)\/([^/]+?)(?:\.git)?$/i); + if (sshAltMatch) { + return { host: sshAltMatch[1], owner: sshAltMatch[2], repo: sshAltMatch[3] }; + } + throw new Error(`Cannot parse remote URL: ${raw}`); +} + +function normalizeBaseUrl(url) { + const raw = String(url || "").trim().replace(/\/+$/, ""); + if (!raw) { + return ""; + } + if (!/^https?:\/\//i.test(raw)) { + throw new Error("GITEA_BASE_URL must start with http:// or https://"); + } + return raw; +} + +function getGiteaRepo() { + const forcedRemote = String(process.env.GITEA_REMOTE || process.env.FORGEJO_REMOTE || "").trim(); + const remotes = forcedRemote + ? [forcedRemote] + : ["gitea", "forgejo", "origin", "github-new", "codeberg"]; + + const preferredBase = normalizeBaseUrl(process.env.GITEA_BASE_URL || process.env.FORGEJO_BASE_URL || "https://git.24-music.de"); + + for (const remote of remotes) { + try { + const remoteUrl = runCapture("git", ["remote", "get-url", remote]); + const parsed = parseRemoteUrl(remoteUrl); + const remoteBase = `https://${parsed.host}`.toLowerCase(); + if (preferredBase && remoteBase !== preferredBase.toLowerCase()) { + continue; + } + return { remote, ...parsed, baseUrl: `https://${parsed.host}` }; + } catch { + // try next remote + } + } + + if (preferredBase) { + throw new Error( + `No remote found for ${preferredBase}. Add one with: git remote add gitea ${preferredBase}//.git` + ); + } + + throw new Error("No suitable remote found. Set GITEA_REMOTE or GITEA_BASE_URL."); +} + +function getAuthHeader(host) { + const explicitToken = String(process.env.GITEA_TOKEN || process.env.FORGEJO_TOKEN || "").trim(); + if (explicitToken) { + return `token ${explicitToken}`; + } + + const credentialText = runWithInput("git", ["credential", "fill"], `protocol=https\nhost=${host}\n\n`); + const map = new Map(); + for (const line of credentialText.split(/\r?\n/)) { + if (!line.includes("=")) { + continue; + } + const [key, value] = line.split("=", 2); + map.set(key, value); + } + const username = map.get("username") || ""; + const password = map.get("password") || ""; + if (!username || !password) { + throw new Error( + `Missing credentials for ${host}. Set GITEA_TOKEN or store credentials for this host in git credential helper.` + ); + } + const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64"); + return `Basic ${token}`; +} + +async function apiRequest(method, url, authHeader, body, contentType = "application/json") { + const headers = { + Accept: "application/json", + Authorization: authHeader + }; + if (body !== undefined) { + headers["Content-Type"] = contentType; + } + const response = await fetch(url, { + method, + headers, + body + }); + const text = await response.text(); + let parsed; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + parsed = text; + } + return { ok: response.ok, status: response.status, body: parsed }; +} + +function ensureVersionString(version) { + const trimmed = String(version || "").trim(); + if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(trimmed)) { + throw new Error("Invalid version format. Expected e.g. 1.4.42"); + } + return trimmed; +} + +function updatePackageVersion(rootDir, version) { + const packagePath = path.join(rootDir, "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); + if (String(packageJson.version || "") === version) { + throw new Error(`package.json is already at version ${version}`); + } + packageJson.version = version; + fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8"); +} + +function patchLatestYml(releaseDir, version) { + const ymlPath = path.join(releaseDir, "latest.yml"); + let content = fs.readFileSync(ymlPath, "utf8"); + const setupName = `Real-Debrid-Downloader Setup ${version}.exe`; + const dashedName = `Real-Debrid-Downloader-Setup-${version}.exe`; + if (content.includes(dashedName)) { + content = content.split(dashedName).join(setupName); + fs.writeFileSync(ymlPath, content, "utf8"); + process.stdout.write(`Patched latest.yml: replaced "${dashedName}" with "${setupName}"\n`); + } +} + +function ensureAssetsExist(rootDir, version) { + const releaseDir = path.join(rootDir, "release"); + const files = [ + `Real-Debrid-Downloader Setup ${version}.exe`, + `Real-Debrid-Downloader ${version}.exe`, + "latest.yml", + `Real-Debrid-Downloader Setup ${version}.exe.blockmap` + ]; + for (const fileName of files) { + const fullPath = path.join(releaseDir, fileName); + if (!fs.existsSync(fullPath)) { + throw new Error(`Missing release artifact: ${fullPath}`); + } + } + patchLatestYml(releaseDir, version); + return { releaseDir, files }; +} + +function ensureNoTrackedChanges() { + const output = runCapture("git", ["status", "--porcelain"]); + const lines = output.split(/\r?\n/).filter(Boolean); + const tracked = lines.filter((line) => !line.startsWith("?? ")); + if (tracked.length > 0) { + throw new Error(`Working tree has tracked changes:\n${tracked.join("\n")}`); + } +} + +function ensureTagMissing(tag) { + const result = spawnSync("git", ["rev-parse", "--verify", `refs/tags/${tag}`], { + cwd: process.cwd(), + stdio: "ignore" + }); + if (result.status === 0) { + throw new Error(`Tag already exists: ${tag}`); + } +} + +async function createOrGetRelease(baseApi, tag, authHeader, notes) { + const byTag = await apiRequest("GET", `${baseApi}/releases/tags/${encodeURIComponent(tag)}`, authHeader); + if (byTag.ok) { + return byTag.body; + } + const payload = { + tag_name: tag, + target_commitish: "main", + name: tag, + body: notes || `Release ${tag}`, + draft: false, + prerelease: false + }; + const created = await apiRequest("POST", `${baseApi}/releases`, authHeader, JSON.stringify(payload)); + if (!created.ok) { + throw new Error(`Failed to create release (${created.status}): ${JSON.stringify(created.body)}`); + } + return created.body; +} + +async function uploadReleaseAssets(baseApi, releaseId, authHeader, releaseDir, files) { + for (const fileName of files) { + const filePath = path.join(releaseDir, fileName); + const fileData = fs.readFileSync(filePath); + const uploadUrl = `${baseApi}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`; + const response = await apiRequest("POST", uploadUrl, authHeader, fileData, "application/octet-stream"); + if (response.ok) { + process.stdout.write(`Uploaded: ${fileName}\n`); + continue; + } + if (response.status === 409 || response.status === 422) { + process.stdout.write(`Skipped existing asset: ${fileName}\n`); + continue; + } + throw new Error(`Asset upload failed for ${fileName} (${response.status}): ${JSON.stringify(response.body)}`); + } +} + +async function main() { + const rootDir = process.cwd(); + const args = parseArgs(process.argv); + if (args.help) { + process.stdout.write("Usage: npm run release:gitea -- [release notes] [--dry-run]\n"); + process.stdout.write("Env: GITEA_BASE_URL, GITEA_REMOTE, GITEA_TOKEN\n"); + process.stdout.write("Compatibility envs still supported: FORGEJO_BASE_URL, FORGEJO_REMOTE, FORGEJO_TOKEN\n"); + process.stdout.write("Example: npm run release:gitea -- 1.6.31 \"- Bugfixes\"\n"); + return; + } + + const version = ensureVersionString(args.version); + const tag = `v${version}`; + const releaseNotes = args.notes || `- Release ${tag}`; + const repo = getGiteaRepo(); + + ensureNoTrackedChanges(); + ensureTagMissing(tag); + updatePackageVersion(rootDir, version); + + process.stdout.write(`Building release artifacts for ${tag}...\n`); + run(NPM_EXECUTABLE, ["run", "release:win"]); + const assets = ensureAssetsExist(rootDir, version); + + if (args.dryRun) { + process.stdout.write(`Dry run complete. Assets exist for ${tag}.\n`); + return; + } + + run("git", ["add", "package.json"]); + run("git", ["commit", "-m", `Release ${tag}`]); + run("git", ["push", repo.remote, "main"]); + run("git", ["tag", tag]); + run("git", ["push", repo.remote, tag]); + + const authHeader = getAuthHeader(repo.host); + const baseApi = `${repo.baseUrl}/api/v1/repos/${repo.owner}/${repo.repo}`; + const release = await createOrGetRelease(baseApi, tag, authHeader, releaseNotes); + await uploadReleaseAssets(baseApi, release.id, authHeader, assets.releaseDir, assets.files); + + process.stdout.write(`Release published: ${release.html_url || `${repo.baseUrl}/${repo.owner}/${repo.repo}/releases/tag/${tag}`}\n`); +} + +main().catch((error) => { + process.stderr.write(`${String(error?.message || error)}\n`); + process.exit(1); +}); diff --git a/src/main/constants.ts b/src/main/constants.ts index 069e043..3d90e06 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -35,7 +35,7 @@ export const MAX_LINK_ARTIFACT_BYTES = 256 * 1024; export const SPEED_WINDOW_SECONDS = 1; export const CLIPBOARD_POLL_INTERVAL_MS = 2000; -export const DEFAULT_UPDATE_REPO = "Sucukdeluxe/real-debrid-downloader"; +export const DEFAULT_UPDATE_REPO = "Administrator/real-debrid-downloader"; export function defaultSettings(): AppSettings { const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid"); diff --git a/src/main/update.ts b/src/main/update.ts index fe11efc..84a9b09 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -14,8 +14,32 @@ const DOWNLOAD_BODY_IDLE_TIMEOUT_MS = 45000; const RETRIES_PER_CANDIDATE = 3; const RETRY_DELAY_MS = 1500; const UPDATE_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`; -const UPDATE_WEB_BASE = "https://codeberg.org"; -const UPDATE_API_BASE = "https://codeberg.org/api/v1"; +type UpdateSource = { + name: string; + webBase: string; + apiBase: string; +}; + +const UPDATE_SOURCES: UpdateSource[] = [ + { + name: "git24", + webBase: "https://git.24-music.de", + apiBase: "https://git.24-music.de/api/v1" + }, + { + name: "codeberg", + webBase: "https://codeberg.org", + apiBase: "https://codeberg.org/api/v1" + }, + { + name: "github", + webBase: "https://github.com", + apiBase: "https://api.github.com" + } +]; +const PRIMARY_UPDATE_SOURCE = UPDATE_SOURCES[0]; +const UPDATE_WEB_BASE = PRIMARY_UPDATE_SOURCE.webBase; +const UPDATE_API_BASE = PRIMARY_UPDATE_SOURCE.apiBase; let activeUpdateAbortController: AbortController | null = null; @@ -57,9 +81,9 @@ export function normalizeUpdateRepo(repo: string): string { const normalizeParts = (input: string): string => { const cleaned = input - .replace(/^https?:\/\/(?:www\.)?(?:codeberg\.org|github\.com)\//i, "") - .replace(/^(?:www\.)?(?:codeberg\.org|github\.com)\//i, "") - .replace(/^git@(?:codeberg\.org|github\.com):/i, "") + .replace(/^https?:\/\/(?:www\.)?(?:codeberg\.org|github\.com|git\.24-music\.de)\//i, "") + .replace(/^(?:www\.)?(?:codeberg\.org|github\.com|git\.24-music\.de)\//i, "") + .replace(/^git@(?:codeberg\.org|github\.com|git\.24-music\.de):/i, "") .replace(/\.git$/i, "") .replace(/^\/+|\/+$/g, ""); const parts = cleaned.split("/").filter(Boolean); @@ -76,7 +100,13 @@ export function normalizeUpdateRepo(repo: string): string { try { const url = new URL(raw); const host = url.hostname.toLowerCase(); - if (host === "codeberg.org" || host === "www.codeberg.org" || host === "github.com" || host === "www.github.com") { + if ( + host === "codeberg.org" + || host === "www.codeberg.org" + || host === "github.com" + || host === "www.github.com" + || host === "git.24-music.de" + ) { const normalized = normalizeParts(url.pathname); if (normalized) { return normalized;