diff --git a/.forgejo/workflows/cleanup.yml b/.forgejo/workflows/cleanup.yml new file mode 100644 index 0000000..48305c8 --- /dev/null +++ b/.forgejo/workflows/cleanup.yml @@ -0,0 +1,56 @@ +on: + workflow_dispatch: + schedule: + - cron: "30 3 * * *" + +jobs: + cleanup-workflow-runs: + runs-on: docker + steps: + - name: Install Packages + run: apt-get update && apt-get install -y curl jq + + - name: Delete workflow runs older than 14 days + env: + TOKEN: ${{ forgejo.token }} + API: ${{ forgejo.api_url }} + REPO: ${{ forgejo.repository }} + CURRENT_RUN_ID: ${{ forgejo.run_id }} + run: | + set -eu + + CUTOFF="$(date -u -d "14 days ago" +"%Y-%m-%dT%H:%M:%SZ")" + PAGE=1 + DELETE_IDS=delete-workflow-runs.txt + + : > "$DELETE_IDS" + + while :; do + curl -fsS \ + -H "Authorization: token $TOKEN" \ + "$API/repos/$REPO/actions/runs?page=$PAGE&limit=50" \ + -o runs.json + + COUNT="$(jq '.workflow_runs | length' runs.json)" + test "$COUNT" -gt 0 || break + + jq -r \ + --arg cutoff "$CUTOFF" \ + --arg current "$CURRENT_RUN_ID" \ + '.workflow_runs[] + | select((.id | tostring) != $current) + | select(["success", "failure", "cancelled", "skipped", "succeeded", "failed"] | index(.status)) + | select(.created < $cutoff) + | .id' runs.json \ + >> "$DELETE_IDS" + + PAGE="$((PAGE + 1))" + done + + while IFS= read -r run_id; do + test -n "$run_id" || continue + echo "Deleting workflow run $run_id" + curl -fsS -X DELETE \ + -H "Authorization: token $TOKEN" \ + "$API/repos/$REPO/actions/runs/$run_id" + done < "$DELETE_IDS" diff --git a/.forgejo/workflows/deploy-prod.yml b/.forgejo/workflows/deploy-prod.yml index ef210bd..6adc499 100644 --- a/.forgejo/workflows/deploy-prod.yml +++ b/.forgejo/workflows/deploy-prod.yml @@ -232,6 +232,46 @@ jobs: -F "attachment=@$file" done + - name: Delete dev releases for prod version + env: + TOKEN: ${{ forgejo.token }} + API: ${{ forgejo.api_url }} + REPO: ${{ forgejo.repository }} + TAG: ${{ steps.version.outputs.tag }} + run: | + set -eu + + PAGE=1 + PREFIX="$TAG-dev-" + DELETE_RELEASES=delete-dev-releases.tsv + + : > "$DELETE_RELEASES" + + while :; do + curl -fsS \ + -H "Authorization: token $TOKEN" \ + "$API/repos/$REPO/releases?page=$PAGE&limit=50&pre-release=true" \ + -o releases.json + + COUNT="$(jq 'length' releases.json)" + test "$COUNT" -gt 0 || break + + jq -r \ + --arg prefix "$PREFIX" \ + '.[] | select(.prerelease == true) | select(.tag_name | startswith($prefix)) | [.id, .tag_name] | @tsv' releases.json \ + >> "$DELETE_RELEASES" + + PAGE="$((PAGE + 1))" + done + + while IFS="$(printf '\t')" read -r release_id release_tag; do + test -n "$release_id" || continue + echo "Deleting dev release $release_tag" + curl -fsS -X DELETE \ + -H "Authorization: token $TOKEN" \ + "$API/repos/$REPO/releases/$release_id" + done < "$DELETE_RELEASES" + - name: Update root flake release hash env: TAG: ${{ steps.version.outputs.tag }} diff --git a/.prettierignore b/.prettierignore index 75752bc..15e3fb5 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,4 +5,5 @@ coverage *.tsbuildinfo bun.lock apps/tauri/src-tauri +apps/electron/release licenses diff --git a/apps/electron/build/icons/128x128.png b/apps/electron/build/icons/128x128.png new file mode 100644 index 0000000..425ffb4 Binary files /dev/null and b/apps/electron/build/icons/128x128.png differ diff --git a/apps/electron/build/icons/128x128@2x.png b/apps/electron/build/icons/128x128@2x.png new file mode 100644 index 0000000..9fe4117 Binary files /dev/null and b/apps/electron/build/icons/128x128@2x.png differ diff --git a/apps/electron/build/icons/32x32.png b/apps/electron/build/icons/32x32.png new file mode 100644 index 0000000..3c9a880 Binary files /dev/null and b/apps/electron/build/icons/32x32.png differ diff --git a/apps/electron/build/icons/64x64.png b/apps/electron/build/icons/64x64.png new file mode 100644 index 0000000..0f9e690 Binary files /dev/null and b/apps/electron/build/icons/64x64.png differ diff --git a/apps/electron/build/icons/icon.icns b/apps/electron/build/icons/icon.icns new file mode 100644 index 0000000..7ea0993 Binary files /dev/null and b/apps/electron/build/icons/icon.icns differ diff --git a/apps/electron/build/icons/icon.ico b/apps/electron/build/icons/icon.ico new file mode 100644 index 0000000..fc67bfb Binary files /dev/null and b/apps/electron/build/icons/icon.ico differ diff --git a/apps/electron/build/icons/icon.png b/apps/electron/build/icons/icon.png new file mode 100644 index 0000000..8e56359 Binary files /dev/null and b/apps/electron/build/icons/icon.png differ diff --git a/apps/electron/package.json b/apps/electron/package.json index 590d97c..aa4d5a0 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -41,6 +41,7 @@ "productName": "Tensamin", "executableName": "tensamin", "artifactName": "Tensamin-${version}-${os}-${arch}.${ext}", + "icon": "build/icons/icon.png", "directories": { "output": "release" }, @@ -52,10 +53,19 @@ { "from": "../web/dist", "to": "web" + }, + { + "from": "build/icons/icon.png", + "to": "icons/icon.png" } ], "linux": { - "target": ["AppImage", "deb", "rpm"], + "target": [ + "AppImage", + "deb", + "rpm" + ], + "icon": "build/icons", "executableName": "tensamin", "category": "Network", "maintainer": "Methanium", @@ -67,10 +77,17 @@ } }, "win": { - "target": ["nsis", "portable"] + "target": [ + "nsis", + "portable" + ], + "icon": "build/icons/icon.ico" }, "mac": { - "target": ["dmg"] + "target": [ + "dmg" + ], + "icon": "build/icons/icon.icns" }, "publish": null } diff --git a/apps/electron/scripts/generate-release-metadata.ts b/apps/electron/scripts/generate-release-metadata.ts index 36a6281..7e36096 100644 --- a/apps/electron/scripts/generate-release-metadata.ts +++ b/apps/electron/scripts/generate-release-metadata.ts @@ -1,5 +1,11 @@ import { createHash } from "node:crypto"; -import { createReadStream, existsSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { + createReadStream, + existsSync, + readdirSync, + statSync, + writeFileSync, +} from "node:fs"; import { basename, join } from "node:path"; import rootPackage from "../../../package.json" with { type: "json" }; @@ -55,7 +61,10 @@ const metadata = { artifacts, }; -writeFileSync(join(outDir, "electron-release-metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`); +writeFileSync( + join(outDir, "electron-release-metadata.json"), + `${JSON.stringify(metadata, null, 2)}\n`, +); writeFileSync( join(outDir, "SHA256SUMS"), `${artifacts.map((artifact) => `${artifact.sha256} ${artifact.name}`).join("\n")}\n`, diff --git a/apps/electron/src/main/main.ts b/apps/electron/src/main/main.ts index a028573..05f88fc 100644 --- a/apps/electron/src/main/main.ts +++ b/apps/electron/src/main/main.ts @@ -1,9 +1,19 @@ import { execFile } from "node:child_process"; import { fileURLToPath } from "node:url"; import { dirname, join, resolve } from "node:path"; -import { app, BrowserWindow, desktopCapturer, ipcMain, session, shell } from "electron"; +import { + app, + BrowserWindow, + desktopCapturer, + ipcMain, + session, + shell, +} from "electron"; import { checkForUpdates } from "./updates.js"; -import { ipcChannels, type DesktopScreenShareCapabilities } from "../shared/ipc.js"; +import { + ipcChannels, + type DesktopScreenShareCapabilities, +} from "../shared/ipc.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const verbose = process.argv.includes("--verbose"); @@ -16,7 +26,11 @@ if (verbose) { app.commandLine.appendSwitch("log-level", "0"); } -if (process.platform === "linux" && process.env.XDG_SESSION_TYPE === "wayland" && !process.env.TENSAMIN_ENABLE_VULKAN) { +if ( + process.platform === "linux" && + process.env.XDG_SESSION_TYPE === "wayland" && + !process.env.TENSAMIN_ENABLE_VULKAN +) { app.commandLine.appendSwitch("disable-features", "Vulkan"); } @@ -34,6 +48,12 @@ function getRendererIndex() { return join(process.resourcesPath, "web", "index.html"); } +function getWindowIcon() { + if (process.platform === "darwin") return undefined; + if (app.isPackaged) return join(process.resourcesPath, "icons", "icon.png"); + return resolve(__dirname, "../../build/icons/icon.png"); +} + function getPlatform(): DesktopScreenShareCapabilities["platform"] { if (process.platform === "linux") return "linux"; if (process.platform === "darwin") return "macos"; @@ -81,7 +101,8 @@ async function listAudioOutputs() { if (!sink || typeof sink !== "object") return null; const record = sink as Record; const id = record.index == null ? undefined : String(record.index); - const name = typeof record.description === "string" ? record.description : id; + const name = + typeof record.description === "string" ? record.description : id; if (!id || !name) return null; return { id, name, isDefault: false }; }) @@ -107,30 +128,34 @@ async function listScreenShareSources() { } function registerDisplayMediaHandler() { - session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => { - verboseLog("display media request", { selectedScreenShareSourceId }); + session.defaultSession.setDisplayMediaRequestHandler( + async (_request, callback) => { + verboseLog("display media request", { selectedScreenShareSourceId }); - const sources = await desktopCapturer.getSources({ - types: ["screen", "window"], - thumbnailSize: { width: 0, height: 0 }, - }); + const sources = await desktopCapturer.getSources({ + types: ["screen", "window"], + thumbnailSize: { width: 0, height: 0 }, + }); - const selected = sources.find((source) => source.id === selectedScreenShareSourceId); - selectedScreenShareSourceId = null; - const video = selected ?? sources[0]; + const selected = sources.find( + (source) => source.id === selectedScreenShareSourceId, + ); + selectedScreenShareSourceId = null; + const video = selected ?? sources[0]; - if (!video) { - callback({}); - return; - } + if (!video) { + callback({}); + return; + } - if (process.platform === "win32") { - callback({ video, audio: "loopback" }); - return; - } + if (process.platform === "win32") { + callback({ video, audio: "loopback" }); + return; + } - callback({ video }); - }); + callback({ video }); + }, + ); } function registerIpc() { @@ -138,16 +163,26 @@ function registerIpc() { ipcMain.handle(ipcChannels.listScreenShareSources, listScreenShareSources); ipcMain.handle(ipcChannels.listScreenShareAudioOutputs, listAudioOutputs); - ipcMain.handle(ipcChannels.getScreenShareCapabilities, getScreenShareCapabilities); - ipcMain.handle(ipcChannels.selectScreenShareSource, (_event, sourceId: unknown) => { - if (typeof sourceId !== "string" || sourceId.length === 0 || sourceId.length > 256) { - throw new Error("Invalid screen share source id."); - } + ipcMain.handle( + ipcChannels.getScreenShareCapabilities, + getScreenShareCapabilities, + ); + ipcMain.handle( + ipcChannels.selectScreenShareSource, + (_event, sourceId: unknown) => { + if ( + typeof sourceId !== "string" || + sourceId.length === 0 || + sourceId.length > 256 + ) { + throw new Error("Invalid screen share source id."); + } - selectedScreenShareSourceId = sourceId; - verboseLog("selected screen share source", sourceId); - return true; - }); + selectedScreenShareSourceId = sourceId; + verboseLog("selected screen share source", sourceId); + return true; + }, + ); ipcMain.handle(ipcChannels.getVersion, () => app.getVersion()); ipcMain.handle(ipcChannels.checkForUpdates, checkForUpdates); ipcMain.handle(ipcChannels.minimizeWindow, () => { @@ -191,6 +226,7 @@ async function createWindow() { minWidth: 900, minHeight: 600, title: "Tensamin", + icon: getWindowIcon(), frame: false, autoHideMenuBar: true, webPreferences: { @@ -210,18 +246,24 @@ async function createWindow() { }); if (verbose) { - mainWindow.webContents.on("console-message", (_event, level, message, line, sourceId) => { - const target = level >= 2 ? console.error : console.log; - target("[tensamin:renderer]", message, { level, line, sourceId }); - }); + mainWindow.webContents.on( + "console-message", + (_event, level, message, line, sourceId) => { + const target = level >= 2 ? console.error : console.log; + target("[tensamin:renderer]", message, { level, line, sourceId }); + }, + ); - mainWindow.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL) => { - console.error("[tensamin:electron] renderer failed to load", { - errorCode, - errorDescription, - validatedURL, - }); - }); + mainWindow.webContents.on( + "did-fail-load", + (_event, errorCode, errorDescription, validatedURL) => { + console.error("[tensamin:electron] renderer failed to load", { + errorCode, + errorDescription, + validatedURL, + }); + }, + ); mainWindow.webContents.on("did-finish-load", () => { verboseLog("renderer finished loading", mainWindow?.webContents.getURL()); diff --git a/apps/electron/src/main/updates.ts b/apps/electron/src/main/updates.ts index 7386118..b5f7376 100644 --- a/apps/electron/src/main/updates.ts +++ b/apps/electron/src/main/updates.ts @@ -3,13 +3,21 @@ import { createReadStream } from "node:fs"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { basename, join } from "node:path"; import { app, net } from "electron"; -import type { ReleaseArtifact, ReleaseMetadata, UpdateCheckResult } from "../shared/ipc.js"; +import type { + ReleaseArtifact, + ReleaseMetadata, + UpdateCheckResult, +} from "../shared/ipc.js"; const metadataUrl = process.env.TENSAMIN_UPDATE_METADATA_URL; function compareSemver(left: string, right: string) { - const leftParts = left.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0); - const rightParts = right.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0); + const leftParts = left + .split(/[.-]/) + .map((part) => Number.parseInt(part, 10) || 0); + const rightParts = right + .split(/[.-]/) + .map((part) => Number.parseInt(part, 10) || 0); const length = Math.max(leftParts.length, rightParts.length); for (let index = 0; index < length; index += 1) { @@ -42,7 +50,9 @@ function requestText(url: string): Promise { const request = net.request(url); request.on("response", (response) => { if (response.statusCode < 200 || response.statusCode >= 300) { - reject(new Error(`Update request failed with HTTP ${response.statusCode}.`)); + reject( + new Error(`Update request failed with HTTP ${response.statusCode}.`), + ); return; } @@ -86,7 +96,9 @@ async function sha256File(filePath: string) { return hash.digest("hex"); } -function selectArtifact(metadata: ReleaseMetadata): ReleaseArtifact | undefined { +function selectArtifact( + metadata: ReleaseMetadata, +): ReleaseArtifact | undefined { const platform = platformName(); const arch = archName(); @@ -102,16 +114,26 @@ export async function checkForUpdates(): Promise { return { available: false, currentVersion, latestVersion: currentVersion }; } - const metadata = JSON.parse(await requestText(metadataUrl)) as ReleaseMetadata; + const metadata = JSON.parse( + await requestText(metadataUrl), + ) as ReleaseMetadata; const artifact = selectArtifact(metadata); if (isDevVersion(metadata.version) !== isDevVersion(currentVersion)) { - return { available: false, currentVersion, latestVersion: metadata.version }; + return { + available: false, + currentVersion, + latestVersion: metadata.version, + }; } if (isDevVersion(currentVersion)) { if (!artifact || metadata.version === currentVersion) { - return { available: false, currentVersion, latestVersion: metadata.version }; + return { + available: false, + currentVersion, + latestVersion: metadata.version, + }; } return { @@ -123,7 +145,11 @@ export async function checkForUpdates(): Promise { } if (!artifact || compareSemver(metadata.version, currentVersion) <= 0) { - return { available: false, currentVersion, latestVersion: metadata.version }; + return { + available: false, + currentVersion, + latestVersion: metadata.version, + }; } return { @@ -140,7 +166,9 @@ export async function downloadVerifiedArtifact(artifact: ReleaseArtifact) { await mkdir(updatesDir, { recursive: true }); const destination = join(updatesDir, basename(artifact.name)); - await writeFile(destination, await requestBuffer(artifact.url), { mode: 0o600 }); + await writeFile(destination, await requestBuffer(artifact.url), { + mode: 0o600, + }); const actualHash = await sha256File(destination); if (actualHash !== artifact.sha256) { diff --git a/apps/tauri/logo.json b/apps/tauri/logo.json index 9cd2fcb..4c85e3b 100644 --- a/apps/tauri/logo.json +++ b/apps/tauri/logo.json @@ -5,4 +5,4 @@ "android_bg": "./background.png", "android_fg_scale": 100, "android_monochrome": "./monochrome.png" -} \ No newline at end of file +} diff --git a/apps/tauri/package.json b/apps/tauri/package.json index 77ba3ea..91c18bd 100644 --- a/apps/tauri/package.json +++ b/apps/tauri/package.json @@ -22,7 +22,7 @@ "build:mobile:raw": "tauri android build", "dev:mobile": "if command -v nix >/dev/null 2>&1; then nix develop --command bun dev:mobile:raw; else bun dev:mobile:raw; fi", "build:mobile": "bun run render-version.ts && if command -v nix >/dev/null 2>&1; then nix develop --command bun build:mobile:raw; else bun build:mobile:raw; fi && bun run render-version.ts --unrender", - "gen-icons": "tauri icon ./logo.json", + "gen-icons": "tauri icon ./logo.json && bun scripts/sync-electron-icons.ts", "format": "bunx prettier --write .", "lint": "eslint src" }, diff --git a/apps/tauri/scripts/sync-electron-icons.ts b/apps/tauri/scripts/sync-electron-icons.ts new file mode 100644 index 0000000..62f232b --- /dev/null +++ b/apps/tauri/scripts/sync-electron-icons.ts @@ -0,0 +1,31 @@ +import { copyFile, mkdir } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const tauriDir = resolve(scriptDir, ".."); +const clientDir = resolve(tauriDir, "../.."); +const tauriIconsDir = join(tauriDir, "src-tauri", "icons"); +const electronIconsDir = join(clientDir, "apps", "electron", "build", "icons"); + +const desktopIcons = [ + "32x32.png", + "64x64.png", + "128x128.png", + "128x128@2x.png", + "icon.png", + "icon.ico", + "icon.icns", +]; + +await mkdir(electronIconsDir, { recursive: true }); + +await Promise.all( + desktopIcons.map((icon) => + copyFile(join(tauriIconsDir, icon), join(electronIconsDir, icon)), + ), +); + +console.log( + `Synced ${desktopIcons.length} desktop icons to ${electronIconsDir}`, +); diff --git a/apps/tauri/src-tauri/icons/icon.icns b/apps/tauri/src-tauri/icons/icon.icns index daf2362..7ea0993 100644 Binary files a/apps/tauri/src-tauri/icons/icon.icns and b/apps/tauri/src-tauri/icons/icon.icns differ diff --git a/eslint.config.ts b/eslint.config.ts index 2209bf9..19d24ae 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -10,7 +10,7 @@ const tsconfigRootDir = dirname(fileURLToPath(import.meta.url)); export default [ { - ignores: ["**/dist/**", "**/node_modules/**", "**/.tmp/**"], + ignores: ["**/dist/**", "**/node_modules/**"], }, js.configs.recommended, ...tseslint.configs.recommended, diff --git a/package.json b/package.json index 2b634e3..0c0e2b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tensamin", - "version": "0.0.6", + "version": "0.0.7", "private": true, "workspaces": [ "packages/*", diff --git a/packages/shared/src/desktopMedia.tsx b/packages/shared/src/desktopMedia.tsx index d117716..4da0119 100644 --- a/packages/shared/src/desktopMedia.tsx +++ b/packages/shared/src/desktopMedia.tsx @@ -26,7 +26,9 @@ type ElectronDesktopApi = { media?: { getScreenShareCapabilities?: () => Promise; listScreenShareSources?: () => Promise; - listScreenShareAudioOutputs?: () => Promise; + listScreenShareAudioOutputs?: () => Promise< + DesktopScreenShareAudioOutput[] + >; selectScreenShareSource?: (sourceId: string) => Promise; }; }; diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..58c21e6 --- /dev/null +++ b/readme.md @@ -0,0 +1,3 @@ +# Information + +All dev releases between two prod version get deleted upon creation of the latest prod release. diff --git a/scripts/copy-releases.ts b/scripts/copy-releases.ts index 3b0eeec..194228a 100644 --- a/scripts/copy-releases.ts +++ b/scripts/copy-releases.ts @@ -60,7 +60,9 @@ const electronReleaseDir = "apps/electron/release"; const copyElectronArtifacts = () => { if (!existsSync(electronReleaseDir)) { - console.warn(`Skipping Electron artifacts: ${electronReleaseDir} does not exist.`); + console.warn( + `Skipping Electron artifacts: ${electronReleaseDir} does not exist.`, + ); return; } @@ -81,7 +83,11 @@ const artifacts = await Promise.all( readdirSync(releasesDir) .map((file) => join(releasesDir, file)) .filter((file) => statSync(file).isFile()) - .filter((file) => !file.endsWith("electron-release-metadata.json") && !file.endsWith("SHA256SUMS")) + .filter( + (file) => + !file.endsWith("electron-release-metadata.json") && + !file.endsWith("SHA256SUMS"), + ) .map(async (filePath) => { const name = filePath.split(/[\\/]/).at(-1)!; diff --git a/tsconfig.json b/tsconfig.json index 0096dc3..5fdf2a0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2020", "module": "nodenext", "lib": ["ES2020"], - "rootDir": "./scripts", + "rootDir": ".", "strict": true, "esModuleInterop": true, "skipLibCheck": true, @@ -12,6 +12,6 @@ "moduleResolution": "nodenext", "types": ["node"] }, - "include": ["scripts"], + "include": ["scripts", "eslint.config.ts"], "exclude": ["node_modules", "dist"] }