Compare commits

...
Author SHA1 Message Date
2440fedaad (feat): add auto update for dev builds on dev builds
All checks were successful
/ build-web (push) Successful in 1m17s
/ build-desktop (linux) (push) Successful in 5m42s
/ build-mobile (push) Successful in 11m33s
/ release (push) Successful in 29s
2026-05-25 14:05:20 +02:00
e146ae2710 (fix): rpm builds in workflow
All checks were successful
/ build-web (push) Successful in 1m25s
/ build-desktop (linux) (push) Successful in 5m45s
/ build-mobile (push) Successful in 11m39s
/ release (push) Successful in 27s
2026-05-25 13:20:50 +02:00
a209ade10b (feat): migrate experimental tauri cef to electron
Some checks failed
/ build-web (push) Successful in 1m19s
/ build-desktop (linux) (push) Failing after 2m33s
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
(feat): add flake to expose tensamin desktop package
(qol): update todo
2026-05-25 13:16:56 +02:00
be80e2f387 (fix): actually fix outdated tag recognition
All checks were successful
/ build-web (push) Successful in 1m13s
/ build-desktop (push) Successful in 12m1s
/ build-mobile (push) Successful in 16m47s
/ release (push) Successful in 22s
2026-05-25 00:58:49 +02:00
15138a30da (fix): outdated tag recognition
All checks were successful
/ build-web (push) Successful in 1m12s
/ build-desktop (push) Successful in 11m50s
/ build-mobile (push) Successful in 16m13s
/ release (push) Successful in 26s
2026-05-25 00:05:48 +02:00
37338f12fb (feat): rename productName to have a uppercase T
Some checks failed
/ build-web (push) Successful in 1m23s
/ release (push) Has been cancelled
/ build-desktop (push) Has been cancelled
/ build-mobile (push) Has been cancelled
2026-05-25 00:00:33 +02:00
edfb037864 (fix): lowercase name on android
Some checks failed
/ build-desktop (push) Has been cancelled
/ release (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-mobile (push) Has been cancelled
2026-05-25 00:00:10 +02:00
d16f260a3a (fix): small desktop app bugs
Some checks failed
/ build-web (push) Successful in 1m12s
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-desktop (push) Has been cancelled
2026-05-24 23:52:33 +02:00
b0cdf0adf2 (fix): actually fix android icons
All checks were successful
/ build-web (push) Successful in 1m11s
/ build-desktop (push) Successful in 11m40s
/ build-mobile (push) Successful in 16m14s
/ release (push) Successful in 28s
2026-05-24 22:24:38 +02:00
0c933e4890 (fix): messages at the top overlapping
All checks were successful
/ build-web (push) Successful in 1m17s
/ build-desktop (push) Successful in 12m24s
/ build-mobile (push) Successful in 17m15s
/ release (push) Successful in 23s
2026-05-24 21:30:42 +02:00
78f354d0d3 (fix): android icons
All checks were successful
/ build-web (push) Successful in 1m22s
/ build-desktop (push) Successful in 12m30s
/ build-mobile (push) Successful in 17m20s
/ release (push) Successful in 24s
2026-05-24 20:50:28 +02:00
d297282320 (fix): chat message measurement
Some checks failed
/ build-web (push) Successful in 1m16s
/ build-desktop (push) Successful in 12m24s
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
(qol): update todos
2026-05-24 20:37:57 +02:00
7ea3b32286 (feat): add native notifications
All checks were successful
/ build-web (push) Successful in 1m9s
/ build-desktop (push) Successful in 11m16s
/ build-mobile (push) Successful in 15m50s
/ release (push) Successful in 23s
(feat): add navbar profile popover
(fix): chat input box click area to slim
(qol): format
2026-05-24 18:31:27 +02:00
76 changed files with 1924 additions and 551 deletions

View file

@ -2,6 +2,8 @@ on:
push: push:
branches: branches:
- dev - dev
paths-ignore:
- flake.nix
jobs: jobs:
build-web: build-web:
@ -11,7 +13,7 @@ jobs:
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: Install Packages - name: Install Packages
run: apt-get update && apt-get install -y sudo curl jq run: apt-get update && apt-get install -y sudo curl jq fakeroot dpkg rpm
- name: Install Nix - name: Install Nix
uses: https://github.com/cachix/install-nix-action@v30 uses: https://github.com/cachix/install-nix-action@v30
@ -44,7 +46,7 @@ jobs:
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: Install Packages - name: Install Packages
run: apt-get update && apt-get install -y sudo curl jq run: apt-get update && apt-get install -y sudo curl jq fakeroot dpkg rpm
- name: Install Nix - name: Install Nix
uses: https://github.com/cachix/install-nix-action@v30 uses: https://github.com/cachix/install-nix-action@v30
@ -80,15 +82,15 @@ jobs:
build-desktop: build-desktop:
runs-on: docker runs-on: docker
strategy:
matrix:
target: [linux]
steps: steps:
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: Install Packages - name: Install Packages
run: apt-get update && apt-get install -y sudo curl jq run: apt-get update && apt-get install -y sudo curl jq fakeroot dpkg rpm xz-utils
- name: Install Nix
uses: https://github.com/cachix/install-nix-action@v30
- name: Install Bun - name: Install Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
@ -102,14 +104,28 @@ jobs:
- name: Build packages - name: Build packages
run: bun run build:packages run: bun run build:packages
- name: Build desktop - name: Set Electron dev version
run: |
VERSION="$(node -p "require('./package.json').version")"
SHORT_SHA="$(git rev-parse --short HEAD)"
DEV_VERSION="$VERSION-dev-$SHORT_SHA"
export DEV_VERSION
node -e '
const fs = require("fs");
const path = "apps/electron/package.json";
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
pkg.version = process.env.DEV_VERSION;
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");
'
- name: Build Electron desktop
run: bun run build:desktop run: bun run build:desktop
- name: Upload desktop artifacts - name: Upload desktop artifacts
uses: https://data.forgejo.org/actions/upload-artifact@v3 uses: https://data.forgejo.org/actions/upload-artifact@v3
with: with:
name: desktop-bundles name: electron-desktop-${{ matrix.target }}
path: apps/tauri/src-tauri/target/release/bundle/ path: apps/electron/release/
release: release:
runs-on: docker runs-on: docker
@ -138,11 +154,8 @@ jobs:
- name: Download desktop artifacts - name: Download desktop artifacts
uses: https://data.forgejo.org/actions/download-artifact@v3 uses: https://data.forgejo.org/actions/download-artifact@v3
with: with:
name: desktop-bundles name: electron-desktop-linux
path: apps/tauri/src-tauri/target/release/bundle/ path: apps/electron/release/
- name: Copy releases
run: bun --bun run copy-releases
- name: Read version and hash - name: Read version and hash
id: version id: version
@ -154,6 +167,15 @@ jobs:
echo "tag=${VERSION}-dev-${SHORT_SHA}" >> "$FORGEJO_OUTPUT" echo "tag=${VERSION}-dev-${SHORT_SHA}" >> "$FORGEJO_OUTPUT"
echo "title=${VERSION}-dev-${SHORT_SHA}" >> "$FORGEJO_OUTPUT" echo "title=${VERSION}-dev-${SHORT_SHA}" >> "$FORGEJO_OUTPUT"
- name: Copy releases
env:
TENSAMIN_RELEASE_VERSION: ${{ steps.version.outputs.tag }}
TENSAMIN_RELEASE_TAG: ${{ steps.version.outputs.tag }}
run: |
ASSET_BASE_URL="${{ forgejo.api_url }}"
ASSET_BASE_URL="${ASSET_BASE_URL%/api/v1}/${{ forgejo.repository }}/releases/download/${{ steps.version.outputs.tag }}"
FORGEJO_RELEASE_ASSET_BASE_URL="$ASSET_BASE_URL" bun --bun run copy-releases
- name: Create pre-release and upload files - name: Create pre-release and upload files
env: env:
TOKEN: ${{ forgejo.token }} TOKEN: ${{ forgejo.token }}
@ -168,7 +190,7 @@ jobs:
test -d releases test -d releases
find releases -type f | grep -q . find releases -type f | grep -q .
LATEST_PROD_TAG="$(git tag --list '*-prod' --sort=-v:refname | head -n 1 || true)" LATEST_PROD_TAG="$(git for-each-ref refs/tags --sort=-creatordate --format='%(refname:short)' | awk '!/-/' | head -n 1 || true)"
if [ -n "$LATEST_PROD_TAG" ]; then if [ -n "$LATEST_PROD_TAG" ]; then
RAW_LOG="$(git log "$LATEST_PROD_TAG"..HEAD --pretty=format:'- %s')" RAW_LOG="$(git log "$LATEST_PROD_TAG"..HEAD --pretty=format:'- %s')"
@ -231,6 +253,20 @@ jobs:
RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)" RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)"
fi fi
ASSET_BASE_URL="${API%/api/v1}/$REPO/releases/download/$TAG"
export ASSET_BASE_URL
node -e '
const fs = require("fs");
const path = "releases/electron-release-metadata.json";
const metadata = JSON.parse(fs.readFileSync(path, "utf8"));
metadata.version = process.env.TAG;
metadata.tag = process.env.TAG;
for (const artifact of metadata.artifacts || []) {
artifact.url = `${process.env.ASSET_BASE_URL}/${encodeURIComponent(artifact.name)}`;
}
fs.writeFileSync(path, `${JSON.stringify(metadata, null, 2)}\n`);
'
find releases -type f -print0 | while IFS= read -r -d '' file; do find releases -type f -print0 | while IFS= read -r -d '' file; do
name="$(basename "$file")" name="$(basename "$file")"
curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$name" \ curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$name" \

View file

@ -2,6 +2,8 @@ on:
push: push:
branches: branches:
- main - main
paths-ignore:
- flake.nix
jobs: jobs:
build-web: build-web:
@ -11,7 +13,7 @@ jobs:
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: Install Packages - name: Install Packages
run: apt-get update && apt-get install -y sudo curl jq run: apt-get update && apt-get install -y sudo curl jq fakeroot dpkg rpm
- name: Install Nix - name: Install Nix
uses: https://github.com/cachix/install-nix-action@v30 uses: https://github.com/cachix/install-nix-action@v30
@ -44,7 +46,7 @@ jobs:
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: Install Packages - name: Install Packages
run: apt-get update && apt-get install -y sudo curl jq run: apt-get update && apt-get install -y sudo curl jq fakeroot dpkg rpm
- name: Install Nix - name: Install Nix
uses: https://github.com/cachix/install-nix-action@v30 uses: https://github.com/cachix/install-nix-action@v30
@ -80,15 +82,15 @@ jobs:
build-desktop: build-desktop:
runs-on: docker runs-on: docker
strategy:
matrix:
target: [linux]
steps: steps:
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: Install Packages - name: Install Packages
run: apt-get update && apt-get install -y sudo curl jq run: apt-get update && apt-get install -y sudo curl jq fakeroot dpkg rpm xz-utils
- name: Install Nix
uses: https://github.com/cachix/install-nix-action@v30
- name: Install Bun - name: Install Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
@ -102,14 +104,26 @@ jobs:
- name: Build packages - name: Build packages
run: bun run build:packages run: bun run build:packages
- name: Build desktop - name: Set Electron prod version
run: |
VERSION="$(node -p "require('./package.json').version")"
export VERSION
node -e '
const fs = require("fs");
const path = "apps/electron/package.json";
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
pkg.version = process.env.VERSION;
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");
'
- name: Build Electron desktop
run: bun run build:desktop run: bun run build:desktop
- name: Upload desktop artifacts - name: Upload desktop artifacts
uses: https://data.forgejo.org/actions/upload-artifact@v3 uses: https://data.forgejo.org/actions/upload-artifact@v3
with: with:
name: desktop-bundles name: electron-desktop-${{ matrix.target }}
path: apps/tauri/src-tauri/target/release/bundle/ path: apps/electron/release/
release: release:
runs-on: docker runs-on: docker
@ -136,11 +150,8 @@ jobs:
- name: Download desktop artifacts - name: Download desktop artifacts
uses: https://data.forgejo.org/actions/download-artifact@v3 uses: https://data.forgejo.org/actions/download-artifact@v3
with: with:
name: desktop-bundles name: electron-desktop-linux
path: apps/tauri/src-tauri/target/release/bundle/ path: apps/electron/release/
- name: Copy releases
run: bun --bun run copy-releases
- name: Read version - name: Read version
id: version id: version
@ -149,6 +160,15 @@ jobs:
echo "version=$VERSION" >> "$FORGEJO_OUTPUT" echo "version=$VERSION" >> "$FORGEJO_OUTPUT"
echo "tag=$VERSION" >> "$FORGEJO_OUTPUT" echo "tag=$VERSION" >> "$FORGEJO_OUTPUT"
- name: Copy releases
env:
TENSAMIN_RELEASE_VERSION: ${{ steps.version.outputs.tag }}
TENSAMIN_RELEASE_TAG: ${{ steps.version.outputs.tag }}
run: |
ASSET_BASE_URL="${{ forgejo.api_url }}"
ASSET_BASE_URL="${ASSET_BASE_URL%/api/v1}/${{ forgejo.repository }}/releases/download/${{ steps.version.outputs.tag }}"
FORGEJO_RELEASE_ASSET_BASE_URL="$ASSET_BASE_URL" bun --bun run copy-releases
- name: Create release and upload files - name: Create release and upload files
env: env:
TOKEN: ${{ forgejo.token }} TOKEN: ${{ forgejo.token }}
@ -190,9 +210,54 @@ jobs:
}')")" }')")"
RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)" RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)"
ASSET_BASE_URL="${API%/api/v1}/$REPO/releases/download/$TAG"
export ASSET_BASE_URL
node -e '
const fs = require("fs");
const path = "releases/electron-release-metadata.json";
const metadata = JSON.parse(fs.readFileSync(path, "utf8"));
metadata.version = process.env.TAG;
metadata.tag = process.env.TAG;
for (const artifact of metadata.artifacts || []) {
artifact.url = `${process.env.ASSET_BASE_URL}/${encodeURIComponent(artifact.name)}`;
}
fs.writeFileSync(path, `${JSON.stringify(metadata, null, 2)}\n`);
'
find releases -type f -print0 | while IFS= read -r -d '' file; do find releases -type f -print0 | while IFS= read -r -d '' file; do
name="$(basename "$file")" name="$(basename "$file")"
curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$name" \ curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$name" \
-H "Authorization: token $TOKEN" \ -H "Authorization: token $TOKEN" \
-F "attachment=@$file" -F "attachment=@$file"
done done
- name: Update root flake release hash
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
set -eu
APPIMAGE="$(find releases -maxdepth 1 -type f -name 'Tensamin-*-linux-x86_64.AppImage' -print -quit)"
test -n "$APPIMAGE"
HASH="$(node -e 'const fs = require("fs"); const crypto = require("crypto"); const file = process.argv[1]; console.log("sha256-" + crypto.createHash("sha256").update(fs.readFileSync(file)).digest("base64"));' "$APPIMAGE")"
export HASH
node -e '
const fs = require("fs");
const version = process.env.TAG;
const hash = process.env.HASH;
let content = fs.readFileSync("flake.nix", "utf8");
content = content.replace(/version = "[^"]+";/, `version = "${version}";`);
content = content.replace(/hash = "sha256-[^"]+";/, `hash = "${hash}";`);
fs.writeFileSync("flake.nix", content);
'
if git diff --quiet -- flake.nix; then
echo "flake.nix already has the current release hash."
exit 0
fi
git add flake.nix
git -c user.name="forgejo-actions" -c user.email="forgejo-actions@localhost" commit -m "(qol): update release flake hash"
git push

2
apps/electron/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
dist
release

61
apps/electron/flake.lock generated Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1779508470,
"narHash": "sha256-Ap9KJX+5xHIn3bPIpfNgT6MEXdAECECwo4/rmlQD74M=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "29916453413845e54a65b8a1cf996842300cd299",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

88
apps/electron/flake.nix Normal file
View file

@ -0,0 +1,88 @@
{
description = "Electron Development Environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = {nixpkgs, flake-utils, ...}:
flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs {inherit system; config.allowUnfree = true;};
electronRuntimeLibs = with pkgs; [
alsa-lib
at-spi2-atk
at-spi2-core
atk
cairo
cups
dbus
expat
fontconfig
freetype
gdk-pixbuf
glib
gtk3
libdrm
libgbm
libglvnd
libnotify
libpulseaudio
libuuid
libxkbcommon
mesa
nspr
nss
pango
pipewire
systemd
wayland
# xorg
libX11
libXScrnSaver
libXcomposite
libXcursor
libXdamage
libXext
libXfixes
libXi
libXrandr
libXtst
libxcb
];
in {
devShells.default = pkgs.mkShell {
packages = with pkgs; [
nodejs_22
corepack_22
bun
electron
pkg-config
python3
gcc
gnumake
git
jq
patchelf
dpkg
rpm
fpm
] ++ electronRuntimeLibs;
shellHook = ''
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath electronRuntimeLibs}:$LD_LIBRARY_PATH"
export ELECTRON_ENABLE_LOGGING=1
export ELECTRON_OZONE_PLATFORM_HINT="''${ELECTRON_OZONE_PLATFORM_HINT:-auto}"
export NPM_CONFIG_TARGET_ARCH="''${NPM_CONFIG_TARGET_ARCH:-x64}"
export npm_config_build_from_source=true
export USE_SYSTEM_FPM=true
alias electron-install='cd ../.. && bun install'
alias electron-build-web='cd ../.. && bun run build:web'
alias electron-dev='bun run dev'
alias electron-package='bun run package:linux'
alias electron-validate='bun run validate'
'';
};
});
}

View file

@ -0,0 +1,69 @@
{
"name": "@tensamin/electron",
"private": true,
"version": "0.0.3",
"description": "Tensamin desktop client",
"author": "methanium",
"homepage": "https://git.methanium.net/tensamin/client",
"type": "module",
"main": "dist/main/main.js",
"scripts": {
"clean": "rm -rf dist release",
"build:web": "cd ../.. && bun run build:web",
"build": "tsc -p tsconfig.json && esbuild src/preload/preload.ts --bundle --platform=node --format=cjs --external:electron --outfile=dist/preload/preload.cjs",
"dev:raw": "cd ../.. && bun run build:web && cd apps/electron && bun run build && electron . --verbose",
"dev": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop --command bun run dev:raw; else bun run dev:raw; fi",
"start:raw": "bun run build && electron .",
"start": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop --command bun run start:raw; else bun run start:raw; fi",
"package:raw": "cd ../.. && bun run build:web && cd apps/electron && bun run build && electron-builder --publish never",
"package": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop --command bun run package:raw; else bun run package:raw; fi",
"package:linux:raw": "cd ../.. && bun run build:web && cd apps/electron && bun run build && electron-builder --linux --publish never",
"package:linux": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop --command bun run package:linux:raw; else bun run package:linux:raw; fi",
"package:windows:raw": "cd ../.. && bun run build:web && cd apps/electron && bun run build && electron-builder --win --publish never",
"package:windows": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop --command bun run package:windows:raw; else bun run package:windows:raw; fi",
"checksum": "bun scripts/generate-release-metadata.ts",
"generate-signing-key": "bun scripts/generate-signing-key.ts",
"validate:raw": "bun run build && bun run package:linux:raw",
"validate": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop --command bun run validate:raw; else bun run validate:raw; fi"
},
"dependencies": {
"electron-updater": "^6.6.2"
},
"devDependencies": {
"@types/node": "^25.9.1",
"electron": "^39.2.7",
"electron-builder": "^26.0.12",
"esbuild": "^0.25.11",
"typescript": "~6.0.3"
},
"build": {
"appId": "net.tensamin.client",
"productName": "Tensamin",
"artifactName": "Tensamin-${version}-${os}-${arch}.${ext}",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"package.json"
],
"extraResources": [
{
"from": "../web/dist",
"to": "web"
}
],
"linux": {
"target": ["AppImage", "deb", "rpm"],
"category": "Network",
"maintainer": "methanium"
},
"win": {
"target": ["nsis", "portable"]
},
"mac": {
"target": ["dmg"]
},
"publish": null
}
}

View file

@ -0,0 +1,62 @@
import { createHash } from "node:crypto";
import { createReadStream, existsSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { basename, join } from "node:path";
import rootPackage from "../../../package.json" with { type: "json" };
const releaseDir = join(import.meta.dir, "..", "release");
const outDir = join(import.meta.dir, "..", "..", "..", "releases");
function sha256(filePath: string) {
const hash = createHash("sha256");
const stream = createReadStream(filePath);
return new Promise<string>((resolve, reject) => {
stream.on("data", (chunk) => hash.update(chunk));
stream.on("error", reject);
stream.on("end", () => resolve(hash.digest("hex")));
});
}
function platformFor(file: string) {
if (/win|nsis|portable|\.exe$/i.test(file)) return "windows";
if (/mac|darwin|\.dmg$/i.test(file)) return "macos";
return "linux";
}
function archFor(file: string) {
if (/arm64|aarch64/i.test(file)) return "arm64";
return "x64";
}
if (!existsSync(releaseDir)) {
throw new Error(`Missing Electron release directory: ${releaseDir}`);
}
const files = readdirSync(releaseDir)
.filter((file) => !file.endsWith(".blockmap") && !file.endsWith(".yml"))
.map((file) => join(releaseDir, file))
.filter((file) => statSync(file).isFile());
const artifacts = await Promise.all(
files.map(async (filePath) => ({
name: basename(filePath),
platform: platformFor(filePath),
arch: archFor(filePath),
url: `__FORGEJO_RELEASE_ASSET_URL__/${encodeURIComponent(basename(filePath))}`,
sha256: await sha256(filePath),
size: statSync(filePath).size,
})),
);
const metadata = {
version: rootPackage.version,
tag: rootPackage.version,
publishedAt: new Date().toISOString(),
artifacts,
};
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`,
);

View file

@ -0,0 +1,11 @@
import { generateKeyPairSync } from "node:crypto";
const { privateKey, publicKey } = generateKeyPairSync("ed25519", {
privateKeyEncoding: { type: "pkcs8", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
});
console.log("TENSAMIN_UPDATE_PRIVATE_KEY_PEM=");
console.log(privateKey.trim());
console.log("\nTENSAMIN_UPDATE_PUBLIC_KEY_PEM=");
console.log(publicKey.trim());

View file

@ -0,0 +1,274 @@
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 { checkForUpdates } from "./updates.js";
import { ipcChannels, type DesktopScreenShareCapabilities } from "../shared/ipc.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const verbose = process.argv.includes("--verbose");
let mainWindow: BrowserWindow | null = null;
let selectedScreenShareSourceId: string | null = null;
if (verbose) {
app.commandLine.appendSwitch("enable-logging", "stderr");
app.commandLine.appendSwitch("v", "1");
app.commandLine.appendSwitch("log-level", "0");
}
if (process.platform === "linux" && process.env.XDG_SESSION_TYPE === "wayland" && !process.env.TENSAMIN_ENABLE_VULKAN) {
app.commandLine.appendSwitch("disable-features", "Vulkan");
}
function verboseLog(...args: unknown[]) {
if (verbose) {
console.log("[tensamin:electron]", ...args);
}
}
function getRendererIndex() {
if (!app.isPackaged) {
return resolve(__dirname, "../../../web/dist/index.html");
}
return join(process.resourcesPath, "web", "index.html");
}
function getPlatform(): DesktopScreenShareCapabilities["platform"] {
if (process.platform === "linux") return "linux";
if (process.platform === "darwin") return "macos";
if (process.platform === "win32") return "windows";
return "other";
}
function getScreenShareCapabilities(): DesktopScreenShareCapabilities {
const platform = getPlatform();
return {
runtime: "electron",
platform,
showAudioOutputSelector: platform === "linux",
showAudioSwitch: platform === "windows" || platform === "macos",
hasReliableSystemAudio: platform === "windows",
};
}
function execJson(command: string, args: string[]) {
verboseLog("exec", command, args.join(" "));
return new Promise<unknown>((resolvePromise, reject) => {
execFile(command, args, { timeout: 3000 }, (error, stdout, stderr) => {
if (error) {
reject(new Error(stderr.trim() || error.message));
return;
}
resolvePromise(JSON.parse(stdout));
});
});
}
async function listAudioOutputs() {
verboseLog("listAudioOutputs", { platform: process.platform });
if (process.platform !== "linux") return [];
const sinks = await execJson("pactl", ["--format=json", "list", "sinks"]);
if (!Array.isArray(sinks)) return [];
return sinks
.map((sink) => {
if (!sink || typeof sink !== "object") return null;
const record = sink as Record<string, unknown>;
const id = record.index == null ? undefined : String(record.index);
const name = typeof record.description === "string" ? record.description : id;
if (!id || !name) return null;
return { id, name, isDefault: false };
})
.filter(Boolean);
}
async function listScreenShareSources() {
verboseLog("listScreenShareSources");
const sources = await desktopCapturer.getSources({
types: ["screen", "window"],
thumbnailSize: { width: 320, height: 180 },
fetchWindowIcons: true,
});
return sources.map((source) => ({
id: source.id,
kind: source.id.startsWith("screen:") ? "screen" : "window",
name: source.name,
subtitle: source.id,
thumbnail: source.thumbnail.isEmpty() ? null : source.thumbnail.toDataURL(),
}));
}
function registerDisplayMediaHandler() {
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 selected = sources.find((source) => source.id === selectedScreenShareSourceId);
selectedScreenShareSourceId = null;
const video = selected ?? sources[0];
if (!video) {
callback({});
return;
}
if (process.platform === "win32") {
callback({ video, audio: "loopback" });
return;
}
callback({ video });
});
}
function registerIpc() {
verboseLog("registering ipc handlers");
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.");
}
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, () => {
verboseLog("window:minimize");
mainWindow?.minimize();
});
ipcMain.handle(ipcChannels.maximizeWindow, () => {
verboseLog("window:maximize");
if (!mainWindow) return;
if (mainWindow.isMaximized()) {
mainWindow.unmaximize();
return;
}
mainWindow.maximize();
});
ipcMain.handle(ipcChannels.closeWindow, () => {
verboseLog("window:close");
mainWindow?.close();
});
}
async function createWindow() {
const rendererIndex = getRendererIndex();
verboseLog("creating main window", {
appVersion: app.getVersion(),
electronVersion: process.versions.electron,
chromeVersion: process.versions.chrome,
nodeVersion: process.versions.node,
platform: process.platform,
arch: process.arch,
isPackaged: app.isPackaged,
rendererIndex,
argv: process.argv,
});
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 900,
minHeight: 600,
title: "Tensamin",
frame: false,
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, "../preload/preload.cjs"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
},
});
mainWindow.setMenuBarVisibility(false);
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
verboseLog("blocked window open", url);
void shell.openExternal(url);
return { action: "deny" };
});
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("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());
});
mainWindow.webContents.on("render-process-gone", (_event, details) => {
console.error("[tensamin:electron] renderer process gone", details);
});
mainWindow.on("unresponsive", () => {
console.error("[tensamin:electron] main window became unresponsive");
});
}
await mainWindow.loadFile(rendererIndex);
}
app.on("window-all-closed", () => {
verboseLog("window-all-closed");
if (process.platform !== "darwin") app.quit();
});
app.on("activate", () => {
verboseLog("activate");
if (BrowserWindow.getAllWindows().length === 0) void createWindow();
});
if (verbose) {
process.on("uncaughtException", (error) => {
console.error("[tensamin:electron] uncaught exception", error);
});
process.on("unhandledRejection", (reason) => {
console.error("[tensamin:electron] unhandled rejection", reason);
});
}
async function start() {
verboseLog("waiting for app readiness");
await app.whenReady();
verboseLog("app ready");
registerIpc();
registerDisplayMediaHandler();
await createWindow();
}
void start().catch((error) => {
console.error("[tensamin:electron] failed to start", error);
app.exit(1);
});

View file

@ -0,0 +1,152 @@
import { createHash } from "node:crypto";
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";
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 length = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < length; index += 1) {
const diff = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
if (diff !== 0) return diff;
}
return 0;
}
function isDevVersion(version: string) {
return /-dev[.-]/.test(version);
}
function platformName() {
if (process.platform === "win32") return "windows";
if (process.platform === "darwin") return "macos";
if (process.platform === "linux") return "linux";
return process.platform;
}
function archName() {
if (process.arch === "x64") return "x64";
if (process.arch === "arm64") return "arm64";
return process.arch;
}
function requestText(url: string): Promise<string> {
return new Promise((resolve, reject) => {
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}.`));
return;
}
const chunks: Buffer[] = [];
response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
response.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
});
request.on("error", reject);
request.end();
});
}
function requestBuffer(url: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
const request = net.request(url);
request.on("response", (response) => {
if (response.statusCode < 200 || response.statusCode >= 300) {
reject(new Error(`Download failed with HTTP ${response.statusCode}.`));
return;
}
const chunks: Buffer[] = [];
response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
response.on("end", () => resolve(Buffer.concat(chunks)));
});
request.on("error", reject);
request.end();
});
}
async function sha256File(filePath: string) {
const hash = createHash("sha256");
await new Promise<void>((resolve, reject) => {
const stream = createReadStream(filePath);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("error", reject);
stream.on("end", resolve);
});
return hash.digest("hex");
}
function selectArtifact(metadata: ReleaseMetadata): ReleaseArtifact | undefined {
const platform = platformName();
const arch = archName();
return metadata.artifacts.find(
(artifact) => artifact.platform === platform && artifact.arch === arch,
);
}
export async function checkForUpdates(): Promise<UpdateCheckResult> {
const currentVersion = app.getVersion();
if (!metadataUrl) {
return { available: false, currentVersion, latestVersion: currentVersion };
}
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 };
}
if (isDevVersion(currentVersion)) {
if (!artifact || metadata.version === currentVersion) {
return { available: false, currentVersion, latestVersion: metadata.version };
}
return {
available: true,
currentVersion,
latestVersion: metadata.version,
artifact,
};
}
if (!artifact || compareSemver(metadata.version, currentVersion) <= 0) {
return { available: false, currentVersion, latestVersion: metadata.version };
}
return {
available: true,
currentVersion,
latestVersion: metadata.version,
artifact,
};
}
export async function downloadVerifiedArtifact(artifact: ReleaseArtifact) {
const updatesDir = join(app.getPath("userData"), "updates");
await rm(updatesDir, { recursive: true, force: true });
await mkdir(updatesDir, { recursive: true });
const destination = join(updatesDir, basename(artifact.name));
await writeFile(destination, await requestBuffer(artifact.url), { mode: 0o600 });
const actualHash = await sha256File(destination);
if (actualHash !== artifact.sha256) {
await rm(destination, { force: true });
throw new Error("Downloaded update failed checksum verification.");
}
return destination;
}

View file

@ -0,0 +1,43 @@
import { contextBridge, ipcRenderer } from "electron";
import { ipcChannels, type DesktopScreenShareSource } from "../shared/ipc.js";
function windowAction(channel: string) {
return () => ipcRenderer.invoke(channel);
}
const desktopApi = {
media: {
listScreenShareSources: () =>
ipcRenderer.invoke(ipcChannels.listScreenShareSources),
listScreenShareAudioOutputs: () =>
ipcRenderer.invoke(ipcChannels.listScreenShareAudioOutputs),
getScreenShareCapabilities: () =>
ipcRenderer.invoke(ipcChannels.getScreenShareCapabilities),
selectScreenShareSource: (sourceId: DesktopScreenShareSource["id"]) => {
if (typeof sourceId !== "string" || sourceId.length === 0) {
return Promise.reject(new Error("Invalid screen share source id."));
}
return ipcRenderer.invoke(ipcChannels.selectScreenShareSource, sourceId);
},
},
app: {
getVersion: () => ipcRenderer.invoke(ipcChannels.getVersion),
},
updates: {
checkForUpdates: () => ipcRenderer.invoke(ipcChannels.checkForUpdates),
},
window: {
minimize: () => windowAction(ipcChannels.minimizeWindow),
maximize: () => windowAction(ipcChannels.maximizeWindow),
close: () => windowAction(ipcChannels.closeWindow),
},
};
contextBridge.exposeInMainWorld("tensaminDesktop", desktopApi);
contextBridge.exposeInMainWorld(
"tensaminShowWindowControls",
process.env.TENSAMIN_HIDE_CONTROLS == null,
);
export type TensaminDesktopApi = typeof desktopApi;

View file

@ -0,0 +1,58 @@
export type DesktopScreenShareSource = {
id: string;
kind: "screen" | "window";
name: string;
subtitle?: string | null;
thumbnail?: string | null;
};
export type DesktopScreenShareAudioOutput = {
id: string;
name: string;
isDefault: boolean;
};
export type DesktopScreenShareCapabilities = {
runtime: "electron";
platform: "linux" | "macos" | "windows" | "other";
showAudioOutputSelector: boolean;
showAudioSwitch: boolean;
hasReliableSystemAudio: boolean;
};
export type ReleaseArtifact = {
name: string;
platform: string;
arch: string;
url: string;
sha256: string;
size: number;
};
export type ReleaseMetadata = {
version: string;
tag: string;
publishedAt: string;
artifacts: ReleaseArtifact[];
};
export type UpdateCheckResult =
| { available: false; currentVersion: string; latestVersion: string }
| {
available: true;
currentVersion: string;
latestVersion: string;
artifact: ReleaseArtifact;
};
export const ipcChannels = {
listScreenShareSources: "desktopMedia:listScreenShareSources",
listScreenShareAudioOutputs: "desktopMedia:listScreenShareAudioOutputs",
getScreenShareCapabilities: "desktopMedia:getScreenShareCapabilities",
selectScreenShareSource: "desktopMedia:selectScreenShareSource",
minimizeWindow: "window:minimize",
maximizeWindow: "window:maximize",
closeWindow: "window:close",
getVersion: "app:getVersion",
checkForUpdates: "updates:checkForUpdates",
} as const;

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"types": ["node", "electron"]
},
"include": ["src/**/*.ts"]
}

BIN
apps/tauri/android.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
apps/tauri/background.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 MiB

View file

@ -1,5 +1,5 @@
{ {
description = "Tauri development environment"; description = "Tauri mobile development environment";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
@ -53,34 +53,6 @@
]; ];
}; };
desktopRuntimeLibs = with pkgs; [
glib
gtk3
gdk-pixbuf
pango
cairo
atk
at-spi2-atk
alsa-lib
dbus
cups
expat
libdrm
libgbm
libglvnd
mesa
libxkbcommon
libx11
libxcomposite
libxdamage
libxext
libxfixes
libxrandr
libxcb
systemd
nspr
nss
];
in { in {
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
buildInputs = with pkgs; buildInputs = with pkgs;
@ -91,7 +63,6 @@
nodejs nodejs
pkg-config pkg-config
] ]
++ desktopRuntimeLibs
++ [ ++ [
android.androidsdk android.androidsdk
pkgs.android-studio-tools pkgs.android-studio-tools
@ -130,7 +101,6 @@
export NDK_HOME="$ANDROID_NDK_ROOT" export NDK_HOME="$ANDROID_NDK_ROOT"
export NDK_PATH="$ANDROID_NDK_ROOT" export NDK_PATH="$ANDROID_NDK_ROOT"
export JAVA_HOME="${pkgs.jdk17}" export JAVA_HOME="${pkgs.jdk17}"
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath desktopRuntimeLibs}:$LD_LIBRARY_PATH"
''; '';
}; };
} }

8
apps/tauri/logo.json Normal file
View file

@ -0,0 +1,8 @@
{
"default": "./logo.svg",
"android_fg": "./android.png",
"android_bg": "./background.png",
"android_fg_scale": 100,
"android_monochrome": "./monochrome.png"
}

BIN
apps/tauri/monochrome.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -4,10 +4,6 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"exports": { "exports": {
"./context": {
"types": "./src/context.tsx",
"default": "./src/context.tsx"
},
"./controls": { "./controls": {
"types": "./src/windowControls.tsx", "types": "./src/windowControls.tsx",
"default": "./src/windowControls.tsx" "default": "./src/windowControls.tsx"
@ -26,11 +22,7 @@
"build:mobile:raw": "tauri android build", "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", "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", "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",
"dev:desktop:raw": "tauri dev", "gen-icons": "tauri icon ./logo.json",
"build:desktop:raw": "tauri build",
"dev:desktop": "if command -v nix >/dev/null 2>&1; then nix develop --command bun dev:desktop:raw; else bun dev:desktop:raw; fi",
"build:desktop": "if command -v nix >/dev/null 2>&1; then nix develop --command bun build:desktop:raw; else bun build:desktop:raw; fi",
"gen-icons": "tauri icon ./logo.svg",
"format": "bunx prettier --write .", "format": "bunx prettier --write .",
"lint": "eslint src" "lint": "eslint src"
}, },

View file

@ -1,5 +0,0 @@
# Outputs
- ./src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk
- ./src-tauri/target/release/bundle/deb/Tensamin*{version}*{arch}.deb
- ./src-tauri/target/release/bundle/rpm/Tensamin-{version}-1.{arch}.rpm

View file

@ -12,9 +12,7 @@ const tauriConfigPath = path.resolve(__dirname, "./src-tauri/tauri.conf.json");
const isUnrender = process.argv.includes("--unrender"); const isUnrender = process.argv.includes("--unrender");
// Version Source // Version Source
const packageJson = JSON.parse( const packageJson = JSON.parse(fs.readFileSync(rootPackageJsonPath, "utf8"));
fs.readFileSync(rootPackageJsonPath, "utf8")
);
const packageVersion: string = packageJson.version; const packageVersion: string = packageJson.version;
@ -22,9 +20,7 @@ if (!packageVersion && !isUnrender) {
throw new Error("No version found in package.json"); throw new Error("No version found in package.json");
} }
const targetVersion = isUnrender const targetVersion = isUnrender ? PLACEHOLDER_VERSION : packageVersion;
? PLACEHOLDER_VERSION
: packageVersion;
// Helpers // Helpers
function updateCargoToml(content: string): string { function updateCargoToml(content: string): string {
@ -34,10 +30,7 @@ function updateCargoToml(content: string): string {
throw new Error("Could not find version field in Cargo.toml"); throw new Error("Could not find version field in Cargo.toml");
} }
return content.replace( return content.replace(regex, `version = "${targetVersion}"`);
regex,
`version = "${targetVersion}"`
);
} }
function updateTauriConfig(content: string): string { function updateTauriConfig(content: string): string {
@ -47,10 +40,7 @@ function updateTauriConfig(content: string): string {
throw new Error("Could not find version field in tauri.conf.json"); throw new Error("Could not find version field in tauri.conf.json");
} }
return content.replace( return content.replace(regex, `"version": "${targetVersion}"`);
regex,
`"version": "${targetVersion}"`
);
} }
// Update Cargo.toml // Update Cargo.toml
@ -72,4 +62,4 @@ if (isUnrender) {
console.log(`Unrendered versions back to ${PLACEHOLDER_VERSION}`); console.log(`Unrendered versions back to ${PLACEHOLDER_VERSION}`);
} else { } else {
console.log(`Rendered version ${targetVersion}`); console.log(`Rendered version ${targetVersion}`);
} }

View file

@ -4838,7 +4838,7 @@ dependencies = [
[[package]] [[package]]
name = "tensamin" name = "tensamin"
version = "0.1.0" version = "0.0.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"image 0.25.10", "image 0.25.10",

View file

@ -1,5 +1,5 @@
[package] [package]
name = "Tensamin" name = "tensamin"
version = "0.0.0" version = "0.0.0"
description = "Privacy focused messanger" description = "Privacy focused messanger"
authors = ["methanium"] authors = ["methanium"]
@ -18,36 +18,16 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
base64 = "0.22"
image = { version = "0.25", default-features = false, features = ["jpeg"] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tauri-plugin-deep-link = "2" tauri-plugin-deep-link = "2"
[target.'cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))'.dependencies]
xcap = "0.4.1"
[target.'cfg(target_os = "android")'.dependencies.tauri] [target.'cfg(target_os = "android")'.dependencies.tauri]
version = "2" version = "2"
features = [] features = []
default-features = true default-features = true
[target.'cfg(target_os = "windows")'.dependencies.tauri]
version = "2"
features = ["compression", "common-controls-v6", "dynamic-acl"]
default-features = true
[target.'cfg(target_os = "linux")'.dependencies.tauri]
version = "2"
features = ["common-controls-v6", "cef", "compression", "dynamic-acl", "x11"]
default-features = false
[target.'cfg(target_os = "macos")'.dependencies.tauri]
version = "2"
features = ["x11", "common-controls-v6", "cef", "compression", "dynamic-acl"]
default-features = false
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] [target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
tauri-plugin-barcode-scanner = "2" tauri-plugin-barcode-scanner = "2"
tauri-plugin-app-events = "0.2" tauri-plugin-app-events = "0.2"

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@mipmap/ic_launcher_background"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon> </adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 6 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Before After
Before After

View file

@ -1,4 +1,4 @@
<resources> <resources>
<string name="app_name">tensamin</string> <string name="app_name">Tensamin</string>
<string name="main_activity_title">tensamin</string> <string name="main_activity_title">Tensamin</string>
</resources> </resources>

Binary file not shown.

View file

@ -1,204 +1,7 @@
use serde::Serialize;
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ScreenShareSource {
id: String,
kind: String,
name: String,
subtitle: Option<String>,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ScreenShareAudioOutput {
id: String,
name: String,
is_default: bool,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ScreenShareCapabilities {
platform: String,
show_audio_output_selector: bool,
show_audio_switch: bool,
has_reliable_system_audio: bool,
}
#[tauri::command]
fn list_screen_share_sources() -> Result<Vec<ScreenShareSource>, String> {
#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]
{
use xcap::{Monitor, Window};
let mut sources = Vec::new();
let mut errors = Vec::new();
match Monitor::all() {
Ok(monitors) => {
for (index, monitor) in monitors.into_iter().enumerate() {
let mut subtitle = None;
if monitor.is_primary().unwrap_or(false) {
subtitle = Some("Primary display".to_string());
}
sources.push(ScreenShareSource {
id: format!("screen:{index}"),
kind: "screen".to_string(),
name: monitor
.name()
.unwrap_or_else(|_| format!("Display {}", index + 1)),
subtitle,
});
}
}
Err(error) => errors.push(format!("display listing failed: {error}")),
}
match Window::all() {
Ok(windows) => {
for (index, window) in windows.into_iter().enumerate() {
if window.is_minimized().unwrap_or(false) {
continue;
}
let title = window.title().unwrap_or_default();
if title.trim().is_empty() {
continue;
}
sources.push(ScreenShareSource {
id: format!("window:{index}"),
kind: "window".to_string(),
name: title,
subtitle: None,
});
}
}
Err(error) => errors.push(format!("window listing failed: {error}")),
}
if sources.is_empty() {
if errors.is_empty() {
return Ok(sources);
}
return Err(errors.join("; "));
}
return Ok(sources);
}
#[allow(unreachable_code)]
Ok(Vec::new())
}
#[tauri::command]
fn list_audio_outputs() -> Result<Vec<ScreenShareAudioOutput>, String> {
#[cfg(target_os = "linux")]
{
use serde_json::Value;
use std::process::Command;
let output = Command::new("pactl")
.args(["--format=json", "list", "sinks"])
.output()
.map_err(|error| error.to_string())?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
let default_sink = Command::new("pactl")
.arg("get-default-sink")
.output()
.ok()
.filter(|result| result.status.success())
.map(|result| String::from_utf8_lossy(&result.stdout).trim().to_string());
let sinks: Value = serde_json::from_slice(&output.stdout).map_err(|error| error.to_string())?;
let sink_entries = sinks
.as_array()
.ok_or_else(|| "Unexpected pactl sink response".to_string())?;
let mut outputs = Vec::new();
for sink in sink_entries {
let Some(index) = sink.get("index").and_then(Value::as_i64) else {
continue;
};
let Some(name) = sink.get("name").and_then(Value::as_str) else {
continue;
};
let description = sink
.get("description")
.and_then(Value::as_str)
.or_else(|| {
sink.get("properties")
.and_then(|properties| properties.get("device.description"))
.and_then(Value::as_str)
})
.unwrap_or(name)
.to_string();
let is_default = default_sink.as_deref() == Some(name);
outputs.push(ScreenShareAudioOutput {
id: index.to_string(),
name: description,
is_default,
});
}
outputs.sort_by_key(|output| !output.is_default);
return Ok(outputs);
}
#[allow(unreachable_code)]
Ok(Vec::new())
}
#[tauri::command]
fn get_screen_share_capabilities() -> ScreenShareCapabilities {
ScreenShareCapabilities {
platform: if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"other"
}
.to_string(),
show_audio_output_selector: cfg!(target_os = "linux"),
show_audio_switch: cfg!(any(target_os = "windows", target_os = "macos")),
has_reliable_system_audio: cfg!(target_os = "windows"),
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let builder = tauri::Builder::default(); let builder = tauri::Builder::default();
#[cfg(any(target_os = "linux", target_os = "macos"))]
let builder = builder.command_line_args([
("enable-media-stream", None::<String>),
("enable-usermedia-screen-capturing", None::<String>),
("allow-http-screen-capture", None::<String>),
#[cfg(target_os = "linux")]
(
"enable-features",
Some("WebRTCPipeWireCapturer".to_string()),
),
]);
let builder = builder let builder = builder
.plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_opener::init()); .plugin(tauri_plugin_opener::init());
@ -225,11 +28,6 @@ pub fn run() {
} }
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![
list_screen_share_sources,
list_audio_outputs,
get_screen_share_capabilities
])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
{ {
eprintln!("error while running tauri application: {error}"); eprintln!("error while running tauri application: {error}");

View file

@ -26,7 +26,7 @@
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["app", "deb", "rpm", "nsis", "msi", "dmg"], "targets": [],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
@ -37,9 +37,6 @@
}, },
"plugins": { "plugins": {
"deep-link": { "deep-link": {
"desktop": {
"schemes": ["tensamin"]
},
"mobile": [ "mobile": [
{ {
"scheme": ["tensamin"], "scheme": ["tensamin"],

View file

@ -7,6 +7,7 @@ import {
} from "react"; } from "react";
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"; import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link";
import { isTauri } from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
import { useIsMobile } from "@tensamin/ui";
type DeeplinkContextValue = { type DeeplinkContextValue = {
deeplinks: readonly string[]; deeplinks: readonly string[];
@ -31,10 +32,10 @@ export default function DeeplinkProvider({
children: ReactNode; children: ReactNode;
}) { }) {
const [deeplinks, setDeeplinks] = useState<string[]>([]); const [deeplinks, setDeeplinks] = useState<string[]>([]);
const isTauriEnv = isTauri(); const isMobile = useIsMobile();
useEffect(() => { useEffect(() => {
if (!isTauriEnv) return; if (!isTauri() || !isMobile) return;
let mounted = true; let mounted = true;
let unlisten: (() => void) | undefined; let unlisten: (() => void) | undefined;
@ -57,7 +58,7 @@ export default function DeeplinkProvider({
mounted = false; mounted = false;
unlisten?.(); unlisten?.();
}; };
}, [isTauriEnv]); }, [isMobile]);
return ( return (
<deeplinkContext.Provider <deeplinkContext.Provider

View file

@ -0,0 +1,34 @@
import type { User } from "@tensamin/user/context";
import { Avatar, AvatarFallback, AvatarImage } from "@tensamin/ui";
import Text from "@tensamin/markdown/text";
export default function Profile({ user }: { user: User }) {
return (
<div className="flex flex-col gap-2">
<div className="flex gap-2 items-center">
<Avatar className="size-10">
<AvatarImage src={user.avatar} />
<AvatarFallback className="text-lg">
{user.display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<p className="text-lg font-semibold">{user.display}</p>
<p className="text-muted-foreground">{user.username}</p>
</div>
</div>
<Text value={user.about || ""} />
<div className="flex flex-col">
<p className="text-muted-foreground text-xs overflow-hidden text-ellipsis whitespace-nowrap">
iota: {user.iota_id}
</p>
<p className="text-muted-foreground text-xs overflow-hidden text-ellipsis whitespace-nowrap">
user: {user.user_id}
</p>
<p className="text-muted-foreground text-xs overflow-hidden text-ellipsis whitespace-nowrap">
pub key: {user.public_key}
</p>
</div>
</div>
);
}

View file

@ -1,7 +1,8 @@
import { Button } from "@tensamin/ui"; import { Button, Popover, PopoverContent, PopoverTrigger } from "@tensamin/ui";
import { import {
ArrowLeft, ArrowLeft,
ChevronDown, ChevronDown,
ChevronUp,
House, House,
Phone, Phone,
Settings, Settings,
@ -17,6 +18,7 @@ import { useState } from "react";
import { SidebarTrigger, useSidebar } from "@tensamin/ui"; import { SidebarTrigger, useSidebar } from "@tensamin/ui";
import { WindowControls as Controls } from "@tensamin/ui"; import { WindowControls as Controls } from "@tensamin/ui";
import { useSession } from "@tensamin/storage/session"; import { useSession } from "@tensamin/storage/session";
import Profile from "./modals/profile";
export default function Navbar({ forMobile }: { forMobile: boolean }) { export default function Navbar({ forMobile }: { forMobile: boolean }) {
const navigate = useNavigate(); const navigate = useNavigate();
@ -33,6 +35,8 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
const [selectOpen, setSelectOpen] = useState(false); const [selectOpen, setSelectOpen] = useState(false);
const [userInfoOpen, setUserInfoOpen] = useState(false);
return ( return (
<div <div
data-tauri-drag-region data-tauri-drag-region
@ -69,16 +73,27 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
<Wrapper <Wrapper
userId={id} userId={id}
component={(user) => ( component={(user) => (
<Button <>
variant="link" <Popover open={userInfoOpen} onOpenChange={setUserInfoOpen}>
className="px-0! text-foreground" <PopoverTrigger
style={{ render={
textDecorationLine: "none", <Button
}} variant="link"
> className="px-0! text-foreground"
<p className="font-medium text-md">{user?.display}</p> style={{
<ChevronDown /> textDecorationLine: "none",
</Button> }}
>
<p className="font-medium text-md">{user?.display}</p>
{userInfoOpen ? <ChevronUp /> : <ChevronDown />}
</Button>
}
/>
<PopoverContent side="bottom">
<Profile user={user} />
</PopoverContent>
</Popover>
</>
)} )}
loading={<Skeleton className="ml-3 w-40 h-5" />} loading={<Skeleton className="ml-3 w-40 h-5" />}
/> />
@ -141,7 +156,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
)} )}
</> </>
)} )}
<Controls className="mr-2 ring-border" /> <Controls className="mr-2" />
</div> </div>
</div> </div>
); );

View file

@ -1,4 +1,4 @@
import { Button, cn } from "@tensamin/ui"; import { Button, cn, useIsMobile } from "@tensamin/ui";
import { Input } from "@tensamin/ui"; import { Input } from "@tensamin/ui";
import { Label } from "@tensamin/ui"; import { Label } from "@tensamin/ui";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
@ -84,6 +84,7 @@ function parseTuFileContent(rawFileContent: string): {
} }
export default function Form() { export default function Form() {
const isMobile = useIsMobile();
const uploadRef = React.useRef<HTMLInputElement | null>(null); const uploadRef = React.useRef<HTMLInputElement | null>(null);
const [isDragging, setIsDragging] = React.useState(false); const [isDragging, setIsDragging] = React.useState(false);
const { save } = useStorage(); const { save } = useStorage();
@ -266,7 +267,7 @@ export default function Form() {
return ( return (
<div className="relative flex md:flex-row flex-col gap-15"> <div className="relative flex md:flex-row flex-col gap-15">
{isTauri() ? ( {isTauri() && isMobile ? (
<> <>
<QrCodeScanner <QrCodeScanner
onData={async (data) => { onData={async (data) => {

View file

@ -4,6 +4,7 @@ import {
RouterProvider, RouterProvider,
createRootRoute, createRootRoute,
createRoute, createRoute,
createHashHistory,
createRouter, createRouter,
} from "@tanstack/react-router"; } from "@tanstack/react-router";
@ -37,7 +38,7 @@ import { useEffect, useState, type ReactNode } from "react";
import Storage from "@tensamin/storage/context"; import Storage from "@tensamin/storage/context";
import Session from "@tensamin/storage/session"; import Session from "@tensamin/storage/session";
import Crypto from "@tensamin/crypto/context"; import Crypto from "@tensamin/crypto/context";
import Mobile from "@tensamin/tauri/context"; import DesktopMediaProvider from "@tensamin/shared/desktopMedia";
import LegalWrapper from "@/features/legal/screen"; import LegalWrapper from "@/features/legal/screen";
@ -126,9 +127,9 @@ function RootShell() {
<LoginWrapper> <LoginWrapper>
<LegalWrapper> <LegalWrapper>
<Crypto> <Crypto>
<Mobile> <DesktopMediaProvider>
<Outlet /> <Outlet />
</Mobile> </DesktopMediaProvider>
</Crypto> </Crypto>
</LegalWrapper> </LegalWrapper>
</LoginWrapper> </LoginWrapper>
@ -271,6 +272,8 @@ const routeTree = rootRoute.addChildren([
const router = createRouter({ const router = createRouter({
routeTree, routeTree,
history:
window.location.protocol === "file:" ? createHashHistory() : undefined,
defaultNotFoundComponent: NotFound, defaultNotFoundComponent: NotFound,
}); });

View file

@ -51,6 +51,7 @@ function deepFilterAssetHeaders(rootDir: string): Plugin {
} }
export default defineConfig({ export default defineConfig({
base: "./",
clearScreen: false, clearScreen: false,
resolve: { resolve: {
tsconfigPaths: true, tsconfigPaths: true,

539
bun.lock

File diff suppressed because it is too large Load diff

26
flake.lock generated Normal file
View file

@ -0,0 +1,26 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1779508470,
"narHash": "sha256-Ap9KJX+5xHIn3bPIpfNgT6MEXdAECECwo4/rmlQD74M=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "29916453413845e54a65b8a1cf996842300cd299",
"type": "github"
},
"original": {
"id": "nixpkgs",
"ref": "nixos-unstable",
"type": "indirect"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

35
flake.nix Normal file
View file

@ -0,0 +1,35 @@
{
description = "Tensamin desktop client";
inputs.nixpkgs.url = "nixpkgs/nixos-unstable";
outputs = {nixpkgs, ...}: let
systems = ["x86_64-linux" "aarch64-linux"];
forAllSystems = nixpkgs.lib.genAttrs systems;
version = "0.0.3";
forgejoBaseUrl = "https://git.methanium.net/tensamin/client/releases/download/${version}";
in {
packages = forAllSystems (system: let
pkgs = import nixpkgs {inherit system;};
artifact = if system == "aarch64-linux" then "Tensamin-${version}-linux-arm64.AppImage" else "Tensamin-${version}-linux-x86_64.AppImage";
appImagePath = /. + builtins.getEnv "TENSAMIN_APPIMAGE";
in {
default = pkgs.appimageTools.wrapType2 {
pname = "tensamin";
inherit version;
src = pkgs.fetchurl {
url = "${forgejoBaseUrl}/${artifact}";
hash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
};
};
localPathForDev = pkgs.appimageTools.wrapType2 {
pname = "tensamin";
inherit version;
src = appImagePath;
};
});
};
}

View file

@ -21,8 +21,9 @@
"preview:web": "cd apps/web && bun run preview", "preview:web": "cd apps/web && bun run preview",
"dev:mobile": "cd apps/tauri && bun dev:mobile", "dev:mobile": "cd apps/tauri && bun dev:mobile",
"build:mobile": "cd apps/tauri && bun run build:mobile", "build:mobile": "cd apps/tauri && bun run build:mobile",
"dev:desktop": "cd apps/tauri && bun dev:desktop", "dev:desktop": "cd apps/electron && bun run dev",
"build:desktop": "cd apps/tauri && bun run build:desktop", "build:desktop": "cd apps/electron && bun run package",
"run:desktop": "TENSAMIN_APPIMAGE=\"$PWD/apps/electron/release/Tensamin-0.0.3-linux-x86_64.AppImage\" nix run .#localPathForDev --impure",
"delete:mobile": "cd apps/tauri && nix develop --command adb uninstall net.tensamin.client" "delete:mobile": "cd apps/tauri && nix develop --command adb uninstall net.tensamin.client"
}, },
"devDependencies": { "devDependencies": {
@ -40,7 +41,7 @@
"jsonc-parser": "^3.3.1" "jsonc-parser": "^3.3.1"
}, },
"overrides": { "overrides": {
"@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.34.tar.gz", "@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.36.tar.gz",
"@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.19.tar.gz" "@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.19.tar.gz"
}, },
"dependencies": { "dependencies": {

View file

@ -20,7 +20,7 @@ import {
type DesktopScreenShareCapabilities, type DesktopScreenShareCapabilities,
type DesktopScreenShareSource, type DesktopScreenShareSource,
useDesktopMedia, useDesktopMedia,
} from "@tensamin/tauri/context"; } from "@tensamin/shared/desktopMedia";
import { toast } from "@tensamin/shared/log"; import { toast } from "@tensamin/shared/log";
import { AppWindow, Loader2, MonitorUp } from "lucide-react"; import { AppWindow, Loader2, MonitorUp } from "lucide-react";
import type { ScreenShareCaptureOptions } from "livekit-client"; import type { ScreenShareCaptureOptions } from "livekit-client";
@ -150,7 +150,9 @@ export default function ScreenShareDialog({
setLoading(true); setLoading(true);
try { try {
if (capabilities.platform === "linux") { if (capabilities.runtime === "electron") {
await startLinuxDesktopScreenShare(selectedSource.id);
} else if (capabilities.platform === "linux") {
await startLinuxDesktopScreenShare(selectedSource.id); await startLinuxDesktopScreenShare(selectedSource.id);
} else { } else {
await setScreenShareEnabled( await setScreenShareEnabled(

View file

@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import { log } from "@tensamin/shared/log"; import { log } from "@tensamin/shared/log";
import { import {
type LocalTrack, type LocalTrack,
@ -22,6 +21,39 @@ type ScreenShareStoreSetState = (
| ((state: ScreenShareStoreState) => Partial<ScreenShareStoreState>), | ((state: ScreenShareStoreState) => Partial<ScreenShareStoreState>),
) => void; ) => void;
declare global {
interface Window {
tensaminDesktop?: {
media?: {
getScreenShareCapabilities?: () => Promise<{
runtime?: "electron" | "tauri";
platform: "linux" | "macos" | "windows" | "other";
showAudioOutputSelector: boolean;
showAudioSwitch: boolean;
hasReliableSystemAudio: boolean;
}>;
listScreenShareSources?: () => Promise<
Array<{
id: string;
kind: "screen" | "window";
name: string;
subtitle?: string | null;
thumbnail?: string | null;
}>
>;
listScreenShareAudioOutputs?: () => Promise<
Array<{
id: string;
name: string;
isDefault: boolean;
}>
>;
selectScreenShareSource?: (sourceId: string) => Promise<boolean>;
};
};
}
}
type ScreenShareControllerOptions = { type ScreenShareControllerOptions = {
room: Room; room: Room;
getState: () => ScreenShareStoreState; getState: () => ScreenShareStoreState;
@ -128,84 +160,26 @@ export function createScreenShareController({
async function startLinuxDesktopScreenShare(sourceId: string) { async function startLinuxDesktopScreenShare(sourceId: string) {
await clearPublishedScreenShare(); await clearPublishedScreenShare();
const canvas = document.createElement("canvas"); if (window.tensaminDesktop?.media?.selectScreenShareSource) {
canvas.width = 1280; await window.tensaminDesktop.media.selectScreenShareSource(sourceId);
canvas.height = 720; const tracks = await room.localParticipant.createScreenTracks({
canvas.style.display = "none"; audio: false,
document.body.appendChild(canvas); video: true,
systemAudio: "exclude",
const context = canvas.getContext("2d"); surfaceSwitching: "exclude",
selfBrowserSurface: "exclude",
if (!context) { contentHint: "detail",
canvas.remove();
throw new Error("Failed to initialize the screen share canvas.");
}
const stream = canvas.captureStream(8);
const videoTrack = stream.getVideoTracks()[0];
if (!videoTrack) {
canvas.remove();
throw new Error("Failed to create a video track for screen sharing.");
}
const image = new Image();
let stopped = false;
let frameRequestInFlight = false;
const renderFrame = async () => {
if (stopped || frameRequestInFlight) {
return;
}
frameRequestInFlight = true;
try {
const dataUrl = await invoke<string>("capture_screen_share_frame", {
sourceId,
});
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () =>
reject(new Error("Failed to decode screen share frame."));
image.src = dataUrl;
});
if (
canvas.width !== image.naturalWidth ||
canvas.height !== image.naturalHeight
) {
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
}
context.drawImage(image, 0, 0, canvas.width, canvas.height);
} finally {
frameRequestInFlight = false;
}
};
await renderFrame();
const interval = window.setInterval(() => {
void renderFrame().catch((error) => {
log(
1,
"call",
"red",
"Failed to capture Linux screen share frame",
error,
);
}); });
}, 125);
await publishScreenShareTracks([videoTrack], () => { await publishScreenShareTracks(tracks, () => {
stopped = true; tracks.forEach((track) => track.stop());
window.clearInterval(interval); });
stream.getTracks().forEach((track) => track.stop()); return;
canvas.remove(); }
});
throw new Error(
`Electron desktop media bridge is unavailable. Cannot capture ${sourceId}.`,
);
} }
async function stopScreenShare() { async function stopScreenShare() {

View file

@ -295,13 +295,15 @@ async function updateLocalParticipantAttributes(
screenSharePreviewLength: attributes.screenSharePreview?.length ?? 0, screenSharePreviewLength: attributes.screenSharePreview?.length ?? 0,
}); });
await getRoom().localParticipant.setAttributes(attributes).catch((error) => { await getRoom()
log(1, "call", "red", "Failed to update local participant attributes", { .localParticipant.setAttributes(attributes)
attributes: Object.keys(attributes), .catch((error) => {
error, log(1, "call", "red", "Failed to update local participant attributes", {
attributes: Object.keys(attributes),
error,
});
throw error;
}); });
throw error;
});
log(2, "call", "purple", "Updated local participant attributes", { log(2, "call", "purple", "Updated local participant attributes", {
attributeKeys: Object.keys(attributes), attributeKeys: Object.keys(attributes),
@ -739,6 +741,14 @@ let screenShareController: ReturnType<
typeof createScreenShareController typeof createScreenShareController
> | null = null; > | null = null;
function getNoiseFilterAssetBaseUrl() {
if (window.location.protocol === "file:") {
return new URL("./assets", document.baseURI).href.replace(/\/$/, "");
}
return "/assets";
}
function getScreenShareController() { function getScreenShareController() {
if (!screenShareController) { if (!screenShareController) {
screenShareController = createScreenShareController({ screenShareController = createScreenShareController({
@ -795,11 +805,13 @@ export async function connect(callId: string) {
syncAllRemoteTrackSubscriptions(); syncAllRemoteTrackSubscriptions();
await getRoom().localParticipant.setMicrophoneEnabled(true).catch((error) => { await getRoom()
log(1, "call", "red", "Failed to enable microphone", error); .localParticipant.setMicrophoneEnabled(true)
toast("error", "Failed to enable microphone."); .catch((error) => {
throw error; log(1, "call", "red", "Failed to enable microphone", error);
}); toast("error", "Failed to enable microphone.");
throw error;
});
syncParticipantState(); syncParticipantState();
} }
@ -1082,7 +1094,7 @@ export function useInitializeCall() {
noiseReductionLevel: 60, noiseReductionLevel: 60,
sampleRate: 48000, sampleRate: 48000,
assetConfig: { assetConfig: {
cdnUrl: "/assets", cdnUrl: getNoiseFilterAssetBaseUrl(),
}, },
}), }),
[], [],

View file

@ -8,7 +8,6 @@
- Admin call actions - Admin call actions
- Timeout - Timeout
- Disconnect - Disconnect
- Desktop-App screenshares
- Context menus - Context menus
- Popout Window - Popout Window
- If micGated=true & isSpeaking=false for 5 seconds show banner with mic detection - Add quality selection

View file

@ -97,6 +97,7 @@ export default function InputComponent({
> >
<CardHeader className="relative p-0 flex flex-col"> <CardHeader className="relative p-0 flex flex-col">
<Input <Input
className="w-full"
paddingY="13px" paddingY="13px"
paddingX="13px" paddingX="13px"
placeholder="Send a message..." placeholder="Send a message..."

View file

@ -8,6 +8,7 @@ import Message from "./components/message";
import { PAGE_SIZE } from "./values"; import { PAGE_SIZE } from "./values";
import { useIsMobile } from "@tensamin/ui"; import { useIsMobile } from "@tensamin/ui";
import { Loader2 } from "lucide-react";
function getDistanceFromBottom(element: HTMLDivElement) { function getDistanceFromBottom(element: HTMLDivElement) {
return element.scrollHeight - (element.scrollTop + element.clientHeight); return element.scrollHeight - (element.scrollTop + element.clientHeight);
@ -101,12 +102,6 @@ export default function Screen() {
const virtualRowCount = const virtualRowCount =
messages.length + (shouldShowConversationStart ? 1 : 0); messages.length + (shouldShowConversationStart ? 1 : 0);
const messagesRef = React.useRef(messages);
React.useEffect(() => {
messagesRef.current = messages;
}, [messages]);
const getItemKey = React.useCallback( const getItemKey = React.useCallback(
(index: number) => { (index: number) => {
if (shouldShowConversationStart && index === 0) { if (shouldShowConversationStart && index === 0) {
@ -114,9 +109,9 @@ export default function Screen() {
} }
const messageIndex = shouldShowConversationStart ? index - 1 : index; const messageIndex = shouldShowConversationStart ? index - 1 : index;
return messagesRef.current[messageIndex]?.send_time ?? index; return messages[messageIndex]?.send_time ?? index;
}, },
[shouldShowConversationStart], [shouldShowConversationStart, messages],
); );
const setMeasurementRef = React.useCallback( const setMeasurementRef = React.useCallback(
@ -360,9 +355,38 @@ export default function Screen() {
return; return;
} }
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; let rafId: number;
isAtBottomRef.current = true; let stableFrames = 0;
setHasScrolledToBottomInitially(true); let lastScrollHeight = 0;
const settleToBottom = () => {
if (!scrollRef.current || hasScrolledToBottomInitially) {
return;
}
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
const currentScrollHeight = scrollRef.current.scrollHeight;
if (currentScrollHeight === lastScrollHeight) {
stableFrames++;
} else {
stableFrames = 0;
lastScrollHeight = currentScrollHeight;
}
if (stableFrames >= 2) {
isAtBottomRef.current = true;
setHasScrolledToBottomInitially(true);
} else {
rafId = requestAnimationFrame(settleToBottom);
}
};
settleToBottom();
return () => {
cancelAnimationFrame(rafId);
};
}, [hasScrolledToBottomInitially, virtualRowCount]); }, [hasScrolledToBottomInitially, virtualRowCount]);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
@ -442,6 +466,19 @@ export default function Screen() {
}} }}
onScroll={handleContainerScroll} onScroll={handleContainerScroll}
> >
{messagesQuery.isPending && (
<div className="flex items-center justify-center pt-10">
<p className="text-foreground/45 flex gap-1 items-center justify-center">
<Loader2 size={18} className="animate-spin" />
Loading...
</p>
</div>
)}
{messagesQuery.isFetchingNextPage && (
<div className="flex items-center justify-center pt-3 pb-1">
<Loader2 size={16} className="animate-spin text-foreground/45" />
</div>
)}
<div <div
className="relative w-full" className="relative w-full"
style={{ style={{

1
packages/chat/todo.md Normal file
View file

@ -0,0 +1 @@
- Add context menu to messages

View file

@ -12,6 +12,7 @@
"build": "tsc -p tsconfig.json --noEmit" "build": "tsc -p tsconfig.json --noEmit"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-router": "^1.169.1",
"@tauri-apps/api": "^2.11.0", "@tauri-apps/api": "^2.11.0",
"@tensamin/crypto": "workspace:*", "@tensamin/crypto": "workspace:*",
"@tensamin/shared": "workspace:*", "@tensamin/shared": "workspace:*",

View file

@ -10,16 +10,17 @@ import { message as messageSchema } from "@tensamin/shared/data";
import { Avatar, AvatarFallback, AvatarImage } from "@tensamin/ui"; import { Avatar, AvatarFallback, AvatarImage } from "@tensamin/ui";
import { isTauri } from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
import { useSession } from "@tensamin/storage/session"; import { useSession } from "@tensamin/storage/session";
import { useNavigate } from "@tanstack/react-router";
export const context = createContext<contextType | undefined>(undefined); export const context = createContext<contextType | undefined>(undefined);
function reduceDisplay(display: string) { async function requestNotificationPermission() {
const words = display.split(" "); if (!("Notification" in window)) return false;
if (words.length === 1) {
return display.slice(0, 2).toUpperCase(); if (Notification.permission === "granted") return true;
} else {
return words[0].charAt(0).toUpperCase() + words[1].charAt(0).toUpperCase(); const permission = await Notification.requestPermission();
} return permission === "granted";
} }
export default function Provider(props: { children: React.ReactNode }) { export default function Provider(props: { children: React.ReactNode }) {
@ -29,6 +30,7 @@ export default function Provider(props: { children: React.ReactNode }) {
const { decryptText, getSharedSecret } = useCrypto(); const { decryptText, getSharedSecret } = useCrypto();
const { addLiveMessage, userId } = useChat(); const { addLiveMessage, userId } = useChat();
const { moveUserIdToTop } = useSession(); const { moveUserIdToTop } = useSession();
const navigate = useNavigate();
useEffect(() => { useEffect(() => {
return subscribePush(async (ttpMessage) => { return subscribePush(async (ttpMessage) => {
@ -54,7 +56,7 @@ export default function Provider(props: { children: React.ReactNode }) {
(await load("settings.read_confirmations")) && (await load("settings.read_confirmations")) &&
userId === sender_id userId === sender_id
) { ) {
await send( void send(
"message_state", "message_state",
{ {
message_state: "read", message_state: "read",
@ -64,7 +66,7 @@ export default function Provider(props: { children: React.ReactNode }) {
}, },
); );
} else { } else {
await send( void send(
"message_state", "message_state",
{ {
message_state: "received", message_state: "received",
@ -92,18 +94,41 @@ export default function Provider(props: { children: React.ReactNode }) {
if (isTauri()) { if (isTauri()) {
console.log("weewoo"); console.log("weewoo");
} else { } else {
sonnerToast(user.display, { const hasPermissions = await requestNotificationPermission();
classNames: {
content: "pl-4", if (hasPermissions) {
}, const notification = new Notification(user.display, {
description: decryptedContent, body: decryptedContent,
icon: ( icon: user.avatar || user.display.slice(0, 2).toUpperCase(),
<Avatar> badge: user.avatar || user.display.slice(0, 2).toUpperCase(),
<AvatarImage src={user.avatar} /> tag: `message-${user.user_id}`,
<AvatarFallback>{reduceDisplay(user.display)}</AvatarFallback> silent: true,
</Avatar> });
),
}); notification.onclick = () => {
window.focus();
navigate({
to: `/chat?id=${user.user_id}`,
});
notification.close();
};
} else {
sonnerToast(user.display, {
classNames: {
content: "pl-4",
},
description: decryptedContent,
icon: (
<Avatar>
<AvatarImage src={user.avatar} />
<AvatarFallback>
{user.display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
),
});
}
} }
} }
}); });
@ -117,6 +142,7 @@ export default function Provider(props: { children: React.ReactNode }) {
userId, userId,
moveUserIdToTop, moveUserIdToTop,
send, send,
navigate,
]); ]);
return ( return (

View file

@ -5,6 +5,7 @@
"type": "module", "type": "module",
"exports": { "exports": {
"./data": "./src/data.ts", "./data": "./src/data.ts",
"./desktopMedia": "./src/desktopMedia.tsx",
"./log": "./src/log.tsx", "./log": "./src/log.tsx",
"./settings": "./src/settings.ts", "./settings": "./src/settings.ts",
"./features/legal/schema": "./src/features/legal/schema.ts", "./features/legal/schema": "./src/features/legal/schema.ts",

View file

@ -1,11 +1,11 @@
import { createContext, useContext, useMemo, type ReactNode } from "react"; import { createContext, useContext, useMemo, type ReactNode } from "react";
import { invoke, isTauri } from "@tauri-apps/api/core";
export type DesktopScreenShareSource = { export type DesktopScreenShareSource = {
id: string; id: string;
kind: "screen" | "window"; kind: "screen" | "window";
name: string; name: string;
subtitle?: string | null; subtitle?: string | null;
thumbnail?: string | null;
}; };
export type DesktopScreenShareAudioOutput = { export type DesktopScreenShareAudioOutput = {
@ -15,12 +15,28 @@ export type DesktopScreenShareAudioOutput = {
}; };
export type DesktopScreenShareCapabilities = { export type DesktopScreenShareCapabilities = {
runtime?: "electron" | "tauri";
platform: "linux" | "macos" | "windows" | "other"; platform: "linux" | "macos" | "windows" | "other";
showAudioOutputSelector: boolean; showAudioOutputSelector: boolean;
showAudioSwitch: boolean; showAudioSwitch: boolean;
hasReliableSystemAudio: boolean; hasReliableSystemAudio: boolean;
}; };
type ElectronDesktopApi = {
media?: {
getScreenShareCapabilities?: () => Promise<DesktopScreenShareCapabilities>;
listScreenShareSources?: () => Promise<DesktopScreenShareSource[]>;
listScreenShareAudioOutputs?: () => Promise<DesktopScreenShareAudioOutput[]>;
selectScreenShareSource?: (sourceId: string) => Promise<boolean>;
};
};
declare global {
interface Window {
tensaminDesktop?: ElectronDesktopApi;
}
}
type DesktopMediaContextValue = { type DesktopMediaContextValue = {
getScreenShareCapabilities: () => Promise<DesktopScreenShareCapabilities>; getScreenShareCapabilities: () => Promise<DesktopScreenShareCapabilities>;
listScreenShareSources: () => Promise<DesktopScreenShareSource[]>; listScreenShareSources: () => Promise<DesktopScreenShareSource[]>;
@ -28,6 +44,7 @@ type DesktopMediaContextValue = {
}; };
const defaultCapabilities: DesktopScreenShareCapabilities = { const defaultCapabilities: DesktopScreenShareCapabilities = {
runtime: undefined,
platform: "other", platform: "other",
showAudioOutputSelector: false, showAudioOutputSelector: false,
showAudioSwitch: false, showAudioSwitch: false,
@ -39,33 +56,29 @@ const desktopMediaContext = createContext<DesktopMediaContextValue | undefined>(
); );
async function listScreenShareSources(): Promise<DesktopScreenShareSource[]> { async function listScreenShareSources(): Promise<DesktopScreenShareSource[]> {
if (!isTauri()) { if (window.tensaminDesktop?.media?.listScreenShareSources) {
return []; return window.tensaminDesktop.media.listScreenShareSources();
} }
return invoke<DesktopScreenShareSource[]>("list_screen_share_sources"); return [];
} }
async function listScreenShareAudioOutputs(): Promise< async function listScreenShareAudioOutputs(): Promise<
DesktopScreenShareAudioOutput[] DesktopScreenShareAudioOutput[]
> { > {
if (!isTauri()) { if (window.tensaminDesktop?.media?.listScreenShareAudioOutputs) {
return []; return window.tensaminDesktop.media.listScreenShareAudioOutputs();
} }
return invoke<DesktopScreenShareAudioOutput[]>( return [];
"list_screen_share_audio_outputs",
);
} }
async function getScreenShareCapabilities(): Promise<DesktopScreenShareCapabilities> { async function getScreenShareCapabilities(): Promise<DesktopScreenShareCapabilities> {
if (!isTauri()) { if (window.tensaminDesktop?.media?.getScreenShareCapabilities) {
return defaultCapabilities; return window.tensaminDesktop.media.getScreenShareCapabilities();
} }
return invoke<DesktopScreenShareCapabilities>( return defaultCapabilities;
"get_screen_share_capabilities",
);
} }
export function useDesktopMedia() { export function useDesktopMedia() {
@ -78,7 +91,11 @@ export function useDesktopMedia() {
return value; return value;
} }
export default function Provider({ children }: { children: ReactNode }) { export default function DesktopMediaProvider({
children,
}: {
children: ReactNode;
}) {
const value = useMemo<DesktopMediaContextValue>( const value = useMemo<DesktopMediaContextValue>(
() => ({ () => ({
getScreenShareCapabilities, getScreenShareCapabilities,

View file

@ -257,7 +257,7 @@ export function Provider(props: {
setErrorDescription( setErrorDescription(
"You could try to restart your Iota, check for updates or check your network connection.", "You could try to restart your Iota, check for updates or check your network connection.",
); );
}) });
}, [connected, subscribePush]); }, [connected, subscribePush]);
useEffect(() => { useEffect(() => {

View file

@ -1,4 +1,6 @@
import { import {
copyFileSync,
createReadStream,
existsSync, existsSync,
mkdirSync, mkdirSync,
readdirSync, readdirSync,
@ -6,11 +8,39 @@ import {
rmSync, rmSync,
statSync, statSync,
} from "node:fs"; } from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path"; import { join } from "node:path";
import packageJson from "../package.json" with { type: "json" }; import packageJson from "../package.json" with { type: "json" };
const { version } = packageJson; const { version } = packageJson;
const releaseVersion = process.env.TENSAMIN_RELEASE_VERSION || version;
const releaseTag = process.env.TENSAMIN_RELEASE_TAG || releaseVersion;
const releasesDir = join("releases"); const releasesDir = join("releases");
const releaseAssetBaseUrl = process.env.FORGEJO_RELEASE_ASSET_BASE_URL;
function sha256(filePath: string) {
const hash = createHash("sha256");
const stream = createReadStream(filePath);
return new Promise<string>((resolve, reject) => {
stream.on("data", (chunk) => hash.update(chunk));
stream.on("error", reject);
stream.on("end", () => resolve(hash.digest("hex")));
});
}
function platformFor(file: string) {
if (/\.apk$/i.test(file)) return "android";
if (/win|nsis|portable|\.exe$/i.test(file)) return "windows";
if (/mac|darwin|\.dmg$/i.test(file)) return "macos";
return "linux";
}
function archFor(file: string) {
if (/arm64|aarch64/i.test(file)) return "arm64";
if (/\.apk$/i.test(file)) return "universal";
return "x64";
}
// directory // directory
rmSync(releasesDir, { recursive: true, force: true }); rmSync(releasesDir, { recursive: true, force: true });
@ -25,56 +55,56 @@ if (existsSync(apkSrc)) {
console.warn(`Skipping .apk: ${apkSrc} does not exist.`); console.warn(`Skipping .apk: ${apkSrc} does not exist.`);
} }
// deb & rpm // Electron desktop artifacts
const debDir = "apps/tauri/src-tauri/target/release/bundle/deb"; const electronReleaseDir = "apps/electron/release";
const rpmDir = "apps/tauri/src-tauri/target/release/bundle/rpm";
const moveBundleArtifact = ( const copyElectronArtifacts = () => {
sourceDir: string, if (!existsSync(electronReleaseDir)) {
extension: ".deb" | ".rpm", console.warn(`Skipping Electron artifacts: ${electronReleaseDir} does not exist.`);
destinationFileName: string,
) => {
if (!existsSync(sourceDir)) {
console.warn(`Skipping ${extension}: ${sourceDir} does not exist.`);
return; return;
} }
const candidates = readdirSync(sourceDir) for (const file of readdirSync(electronReleaseDir)) {
.filter((file) => file.endsWith(extension)) if (file.endsWith(".blockmap") || file.endsWith(".yml")) continue;
.map((file) => { if (!file.includes(version) && !file.includes(releaseVersion)) continue;
const filePath = join(sourceDir, file);
return { const source = join(electronReleaseDir, file);
file, if (!statSync(source).isFile()) continue;
filePath,
matchesVersion: file.includes(version),
modifiedAt: statSync(filePath).mtimeMs,
};
})
.sort((a, b) => {
if (a.matchesVersion !== b.matchesVersion) {
return Number(b.matchesVersion) - Number(a.matchesVersion);
}
return b.modifiedAt - a.modifiedAt; copyFileSync(source, join(releasesDir, file));
});
const selected = candidates[0];
if (!selected) {
console.warn(`Skipping ${extension}: no files found in ${sourceDir}.`);
return;
} }
if (!selected.matchesVersion) {
console.warn(
`Using ${selected.file} for ${extension} even though it does not include version ${version}.`,
);
}
renameSync(selected.filePath, join(releasesDir, destinationFileName));
}; };
moveBundleArtifact(debDir, ".deb", `Tensamin-${version}.deb`); copyElectronArtifacts();
moveBundleArtifact(rpmDir, ".rpm", `Tensamin-${version}.rpm`);
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"))
.map(async (filePath) => {
const name = filePath.split(/[\\/]/).at(-1)!;
return {
name,
platform: platformFor(name),
arch: archFor(name),
url: releaseAssetBaseUrl
? `${releaseAssetBaseUrl.replace(/\/$/, "")}/${encodeURIComponent(name)}`
: `__FORGEJO_RELEASE_ASSET_URL__/${encodeURIComponent(name)}`,
sha256: await sha256(filePath),
size: statSync(filePath).size,
};
}),
);
await Bun.write(
join(releasesDir, "electron-release-metadata.json"),
`${JSON.stringify({ version: releaseVersion, tag: releaseTag, publishedAt: new Date().toISOString(), artifacts: artifacts.filter((artifact) => artifact.platform !== "android") }, null, 2)}\n`,
);
await Bun.write(
join(releasesDir, "SHA256SUMS"),
`${artifacts.map((artifact) => `${artifact.sha256} ${artifact.name}`).join("\n")}\n`,
);
console.log("Releases copied successfully."); console.log("Releases copied successfully.");