Merge branch 'dev'
|
|
@ -2,22 +2,35 @@ on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- dev
|
- dev
|
||||||
|
paths-ignore:
|
||||||
|
- flake.nix
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
build-web:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
|
||||||
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: Setup Bun
|
- name: Install Packages
|
||||||
|
run: apt-get update && apt-get install -y sudo curl jq fakeroot dpkg rpm
|
||||||
|
|
||||||
|
- name: Install Nix
|
||||||
|
uses: https://github.com/cachix/install-nix-action@v30
|
||||||
|
|
||||||
|
- name: Install Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Build
|
- name: Copy licenses
|
||||||
|
run: bun run copy-licenses
|
||||||
|
|
||||||
|
- name: Build packages
|
||||||
|
run: bun run build:packages
|
||||||
|
|
||||||
|
- name: Build web
|
||||||
run: bun run build:web
|
run: bun run build:web
|
||||||
|
|
||||||
- name: Install rsync
|
- name: Install rsync
|
||||||
|
|
@ -25,3 +38,238 @@ jobs:
|
||||||
|
|
||||||
- name: Deploy
|
- name: Deploy
|
||||||
run: rsync -a --delete apps/web/dist/ /var/lib/www/tensamin-web-dev/
|
run: rsync -a --delete apps/web/dist/ /var/lib/www/tensamin-web-dev/
|
||||||
|
|
||||||
|
build-mobile:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Check out repo
|
||||||
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Packages
|
||||||
|
run: apt-get update && apt-get install -y sudo curl jq fakeroot dpkg rpm
|
||||||
|
|
||||||
|
- name: Install Nix
|
||||||
|
uses: https://github.com/cachix/install-nix-action@v30
|
||||||
|
|
||||||
|
- name: Install Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Copy licenses
|
||||||
|
run: bun run copy-licenses
|
||||||
|
|
||||||
|
- name: Build packages
|
||||||
|
run: bun run build:packages
|
||||||
|
|
||||||
|
- name: Setup Android Keystore
|
||||||
|
env:
|
||||||
|
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
|
||||||
|
run: |
|
||||||
|
bun -e "require('fs').writeFileSync('keystore.jks', Buffer.from(process.env.KEYSTORE_BASE64.replace(/\s+/g, ''), 'base64'))"
|
||||||
|
bun -e "const content = process.env.KEYSTORE_PROPERTIES.replace(/\\n/g, '\n').replace(/\r/g, '').split('\n').map(l => l.trim()).filter(l => l).join('\n'); require('fs').writeFileSync('keystore.properties', content)"
|
||||||
|
|
||||||
|
- name: Build mobile
|
||||||
|
run: bun run build:mobile
|
||||||
|
|
||||||
|
- name: Upload mobile artifact
|
||||||
|
uses: https://data.forgejo.org/actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: mobile-apk
|
||||||
|
path: apps/tauri/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk
|
||||||
|
|
||||||
|
build-desktop:
|
||||||
|
runs-on: docker
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
target: [linux]
|
||||||
|
steps:
|
||||||
|
- name: Check out repo
|
||||||
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Packages
|
||||||
|
run: apt-get update && apt-get install -y sudo curl jq fakeroot dpkg rpm xz-utils
|
||||||
|
|
||||||
|
- name: Install Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Copy licenses
|
||||||
|
run: bun run copy-licenses
|
||||||
|
|
||||||
|
- name: Build packages
|
||||||
|
run: bun run build:packages
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- name: Upload desktop artifacts
|
||||||
|
uses: https://data.forgejo.org/actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: electron-desktop-${{ matrix.target }}
|
||||||
|
path: apps/electron/release/
|
||||||
|
|
||||||
|
release:
|
||||||
|
runs-on: docker
|
||||||
|
needs: [build-web, build-mobile, build-desktop]
|
||||||
|
steps:
|
||||||
|
- name: Check out repo
|
||||||
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Packages
|
||||||
|
run: apt-get update && apt-get install -y sudo curl jq
|
||||||
|
|
||||||
|
- name: Install Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Download mobile artifact
|
||||||
|
uses: https://data.forgejo.org/actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: mobile-apk
|
||||||
|
path: apps/tauri/src-tauri/gen/android/app/build/outputs/apk/universal/release/
|
||||||
|
|
||||||
|
- name: Download desktop artifacts
|
||||||
|
uses: https://data.forgejo.org/actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: electron-desktop-linux
|
||||||
|
path: apps/electron/release/
|
||||||
|
|
||||||
|
- name: Read version and hash
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION="$(node -p "require('./package.json').version")"
|
||||||
|
SHORT_SHA="$(git rev-parse --short HEAD)"
|
||||||
|
echo "version=$VERSION" >> "$FORGEJO_OUTPUT"
|
||||||
|
echo "short_sha=$SHORT_SHA" >> "$FORGEJO_OUTPUT"
|
||||||
|
echo "tag=${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
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ forgejo.token }}
|
||||||
|
API: ${{ forgejo.api_url }}
|
||||||
|
REPO: ${{ forgejo.repository }}
|
||||||
|
SHA: ${{ forgejo.sha }}
|
||||||
|
TAG: ${{ steps.version.outputs.tag }}
|
||||||
|
TITLE: ${{ steps.version.outputs.title }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
test -d releases
|
||||||
|
find releases -type f | grep -q .
|
||||||
|
|
||||||
|
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
|
||||||
|
RAW_LOG="$(git log "$LATEST_PROD_TAG"..HEAD --pretty=format:'- %s')"
|
||||||
|
else
|
||||||
|
RAW_LOG="$(git log --pretty=format:'- %s')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
export RAW_LOG LATEST_PROD_TAG API REPO TAG
|
||||||
|
BODY="$(node -e '
|
||||||
|
const raw = process.env.RAW_LOG;
|
||||||
|
const lines = raw.split("\n");
|
||||||
|
const commits = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const msg = line.replace(/^- /, "");
|
||||||
|
const parts = msg.split(/(?=\([^)]+\):)/).filter(Boolean).map(s => s.trim()).filter(s => s);
|
||||||
|
for (const part of parts) {
|
||||||
|
commits.push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const priority = { "(feat):": 1, "(fix):": 2, "(qol):": 3 };
|
||||||
|
commits.sort((a, b) => {
|
||||||
|
const tagA = a.match(/^(\([^)]+\):)/)?.[1] || "";
|
||||||
|
const tagB = b.match(/^(\([^)]+\):)/)?.[1] || "";
|
||||||
|
return (priority[tagA] || 99) - (priority[tagB] || 99);
|
||||||
|
});
|
||||||
|
const log = commits.map(c => "- " + c).join("\n");
|
||||||
|
const latestTag = process.env.LATEST_PROD_TAG;
|
||||||
|
if (latestTag) {
|
||||||
|
const serverUrl = process.env.API.replace(/\/api\/v1.*/, "");
|
||||||
|
const compareUrl = serverUrl + "/" + process.env.REPO + "/compare/" + latestTag + "..." + process.env.TAG;
|
||||||
|
console.log(log + "\n\n[View changes](" + compareUrl + ")");
|
||||||
|
} else {
|
||||||
|
console.log(log);
|
||||||
|
}
|
||||||
|
')"
|
||||||
|
|
||||||
|
HTTP_STATUS=$(curl -s -w "%{http_code}" -o release_out.json -H "Authorization: token $TOKEN" "$API/repos/$REPO/releases/tags/$TAG")
|
||||||
|
|
||||||
|
if [ "$HTTP_STATUS" = "200" ]; then
|
||||||
|
echo "Release $TAG already exists."
|
||||||
|
RELEASE_ID="$(jq -r .id release_out.json)"
|
||||||
|
else
|
||||||
|
echo "Creating new pre-release for $TAG"
|
||||||
|
RELEASE_JSON="$(curl -f -sS -X POST "$API/repos/$REPO/releases" \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n \
|
||||||
|
--arg tag "$TAG" \
|
||||||
|
--arg name "$TITLE" \
|
||||||
|
--arg body "$BODY" \
|
||||||
|
--arg target "$SHA" \
|
||||||
|
'{
|
||||||
|
tag_name: $tag,
|
||||||
|
name: $name,
|
||||||
|
body: $body,
|
||||||
|
target_commitish: $target,
|
||||||
|
draft: false,
|
||||||
|
prerelease: true
|
||||||
|
}')")"
|
||||||
|
RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)"
|
||||||
|
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
|
||||||
|
name="$(basename "$file")"
|
||||||
|
curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$name" \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
-F "attachment=@$file"
|
||||||
|
done
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,18 @@ on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- flake.nix
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
build-web:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
- name: Install Nix
|
- name: Install Nix
|
||||||
uses: https://github.com/cachix/install-nix-action@v30
|
uses: https://github.com/cachix/install-nix-action@v30
|
||||||
|
|
@ -23,16 +24,14 @@ jobs:
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Setup Android Keystore
|
- name: Copy licenses
|
||||||
env:
|
run: bun run copy-licenses
|
||||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
||||||
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
|
|
||||||
run: |
|
|
||||||
bun -e "require('fs').writeFileSync('keystore.jks', Buffer.from(process.env.KEYSTORE_BASE64.replace(/\s+/g, ''), 'base64'))"
|
|
||||||
bun -e "const content = process.env.KEYSTORE_PROPERTIES.replace(/\\\\n/g, '\n').replace(/\\r/g, '').split('\n').map(l => l.trim()).filter(l => l).join('\n'); require('fs').writeFileSync('keystore.properties', content)"
|
|
||||||
|
|
||||||
- name: Build
|
- name: Build packages
|
||||||
run: bun run build:apps
|
run: bun run build:packages
|
||||||
|
|
||||||
|
- name: Build web
|
||||||
|
run: bun run build:web
|
||||||
|
|
||||||
- name: Install rsync
|
- name: Install rsync
|
||||||
run: apt-get update && apt-get install -y rsync
|
run: apt-get update && apt-get install -y rsync
|
||||||
|
|
@ -40,12 +39,135 @@ jobs:
|
||||||
- name: Deploy
|
- name: Deploy
|
||||||
run: rsync -a --delete apps/web/dist/ /var/lib/www/tensamin-web-prod/
|
run: rsync -a --delete apps/web/dist/ /var/lib/www/tensamin-web-prod/
|
||||||
|
|
||||||
|
build-mobile:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Check out repo
|
||||||
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Packages
|
||||||
|
run: apt-get update && apt-get install -y sudo curl jq fakeroot dpkg rpm
|
||||||
|
|
||||||
|
- name: Install Nix
|
||||||
|
uses: https://github.com/cachix/install-nix-action@v30
|
||||||
|
|
||||||
|
- name: Install Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Copy licenses
|
||||||
|
run: bun run copy-licenses
|
||||||
|
|
||||||
|
- name: Build packages
|
||||||
|
run: bun run build:packages
|
||||||
|
|
||||||
|
- name: Setup Android Keystore
|
||||||
|
env:
|
||||||
|
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
|
||||||
|
run: |
|
||||||
|
bun -e "require('fs').writeFileSync('keystore.jks', Buffer.from(process.env.KEYSTORE_BASE64.replace(/\s+/g, ''), 'base64'))"
|
||||||
|
bun -e "const content = process.env.KEYSTORE_PROPERTIES.replace(/\\n/g, '\n').replace(/\r/g, '').split('\n').map(l => l.trim()).filter(l => l).join('\n'); require('fs').writeFileSync('keystore.properties', content)"
|
||||||
|
|
||||||
|
- name: Build mobile
|
||||||
|
run: bun run build:mobile
|
||||||
|
|
||||||
|
- name: Upload mobile artifact
|
||||||
|
uses: https://data.forgejo.org/actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: mobile-apk
|
||||||
|
path: apps/tauri/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk
|
||||||
|
|
||||||
|
build-desktop:
|
||||||
|
runs-on: docker
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
target: [linux]
|
||||||
|
steps:
|
||||||
|
- name: Check out repo
|
||||||
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Packages
|
||||||
|
run: apt-get update && apt-get install -y sudo curl jq fakeroot dpkg rpm xz-utils
|
||||||
|
|
||||||
|
- name: Install Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Copy licenses
|
||||||
|
run: bun run copy-licenses
|
||||||
|
|
||||||
|
- name: Build packages
|
||||||
|
run: bun run build:packages
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- name: Upload desktop artifacts
|
||||||
|
uses: https://data.forgejo.org/actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: electron-desktop-${{ matrix.target }}
|
||||||
|
path: apps/electron/release/
|
||||||
|
|
||||||
|
release:
|
||||||
|
runs-on: docker
|
||||||
|
needs: [build-web, build-mobile, build-desktop]
|
||||||
|
steps:
|
||||||
|
- name: Check out repo
|
||||||
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Packages
|
||||||
|
run: apt-get update && apt-get install -y sudo curl jq
|
||||||
|
|
||||||
|
- name: Install Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Download mobile artifact
|
||||||
|
uses: https://data.forgejo.org/actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: mobile-apk
|
||||||
|
path: apps/tauri/src-tauri/gen/android/app/build/outputs/apk/universal/release/
|
||||||
|
|
||||||
|
- name: Download desktop artifacts
|
||||||
|
uses: https://data.forgejo.org/actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: electron-desktop-linux
|
||||||
|
path: apps/electron/release/
|
||||||
|
|
||||||
- name: Read version
|
- name: Read version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
VERSION="$(node -p "require('./package.json').version")"
|
VERSION="$(node -p "require('./package.json').version")"
|
||||||
echo "version=$VERSION" >> "$FORGEJO_OUTPUT"
|
echo "version=$VERSION" >> "$FORGEJO_OUTPUT"
|
||||||
echo "tag=v$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:
|
||||||
|
|
@ -54,19 +176,21 @@ jobs:
|
||||||
REPO: ${{ forgejo.repository }}
|
REPO: ${{ forgejo.repository }}
|
||||||
SHA: ${{ forgejo.sha }}
|
SHA: ${{ forgejo.sha }}
|
||||||
TAG: ${{ steps.version.outputs.tag }}
|
TAG: ${{ steps.version.outputs.tag }}
|
||||||
VERSION: ${{ steps.version.outputs.version }}
|
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
test -d releases
|
test -d releases
|
||||||
find releases -type f | grep -q .
|
find releases -type f | grep -q .
|
||||||
|
|
||||||
|
COMMIT_MSG="$(git log -1 --pretty=%B | sed 's/$/ /')"
|
||||||
|
|
||||||
HTTP_STATUS=$(curl -s -w "%{http_code}" -o release_out.json -H "Authorization: token $TOKEN" "$API/repos/$REPO/releases/tags/$TAG")
|
HTTP_STATUS=$(curl -s -w "%{http_code}" -o release_out.json -H "Authorization: token $TOKEN" "$API/repos/$REPO/releases/tags/$TAG")
|
||||||
|
|
||||||
if [ "$HTTP_STATUS" = "200" ]; then
|
if [ "$HTTP_STATUS" = "200" ]; then
|
||||||
echo "Release $TAG already exists."
|
echo "Release $TAG already exists."
|
||||||
RELEASE_ID="$(jq -r .id release_out.json)"
|
exit 0
|
||||||
else
|
fi
|
||||||
|
|
||||||
echo "Creating new release for $TAG"
|
echo "Creating new release for $TAG"
|
||||||
RELEASE_JSON="$(curl -f -sS -X POST "$API/repos/$REPO/releases" \
|
RELEASE_JSON="$(curl -f -sS -X POST "$API/repos/$REPO/releases" \
|
||||||
-H "Authorization: token $TOKEN" \
|
-H "Authorization: token $TOKEN" \
|
||||||
|
|
@ -74,7 +198,7 @@ jobs:
|
||||||
-d "$(jq -n \
|
-d "$(jq -n \
|
||||||
--arg tag "$TAG" \
|
--arg tag "$TAG" \
|
||||||
--arg name "$TAG" \
|
--arg name "$TAG" \
|
||||||
--arg body "Release $VERSION" \
|
--arg body "$COMMIT_MSG" \
|
||||||
--arg target "$SHA" \
|
--arg target "$SHA" \
|
||||||
'{
|
'{
|
||||||
tag_name: $tag,
|
tag_name: $tag,
|
||||||
|
|
@ -85,7 +209,20 @@ jobs:
|
||||||
prerelease: false
|
prerelease: false
|
||||||
}')")"
|
}')")"
|
||||||
RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)"
|
RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)"
|
||||||
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")"
|
||||||
|
|
@ -93,3 +230,34 @@ jobs:
|
||||||
-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
|
||||||
|
|
|
||||||
3
.vscode/extensions.json
vendored
|
|
@ -2,7 +2,6 @@
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
"tauri-apps.tauri-vscode",
|
"tauri-apps.tauri-vscode",
|
||||||
"rust-lang.rust-analyzer",
|
"rust-lang.rust-analyzer",
|
||||||
"bradlc.vscode-tailwindcss",
|
"bradlc.vscode-tailwindcss"
|
||||||
"antfu.vite"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
apps/electron/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
dist
|
||||||
|
release
|
||||||
61
apps/electron/flake.lock
generated
Normal 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
|
|
@ -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'
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
77
apps/electron/package.json
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"executableName": "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"],
|
||||||
|
"executableName": "tensamin",
|
||||||
|
"category": "Network",
|
||||||
|
"maintainer": "Methanium",
|
||||||
|
"desktop": {
|
||||||
|
"entry": {
|
||||||
|
"Name": "Tensamin",
|
||||||
|
"StartupWMClass": "Tensamin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": ["nsis", "portable"]
|
||||||
|
},
|
||||||
|
"mac": {
|
||||||
|
"target": ["dmg"]
|
||||||
|
},
|
||||||
|
"publish": null
|
||||||
|
}
|
||||||
|
}
|
||||||
62
apps/electron/scripts/generate-release-metadata.ts
Normal 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`,
|
||||||
|
);
|
||||||
11
apps/electron/scripts/generate-signing-key.ts
Normal 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());
|
||||||
274
apps/electron/src/main/main.ts
Normal 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);
|
||||||
|
});
|
||||||
152
apps/electron/src/main/updates.ts
Normal 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;
|
||||||
|
}
|
||||||
43
apps/electron/src/preload/preload.ts
Normal 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;
|
||||||
58
apps/electron/src/shared/ipc.ts
Normal 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;
|
||||||
14
apps/electron/tsconfig.json
Normal 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
|
After Width: | Height: | Size: 53 KiB |
BIN
apps/tauri/background.png
Normal file
|
After Width: | Height: | Size: 7.7 MiB |
|
|
@ -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
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"default": "./logo.svg",
|
||||||
|
|
||||||
|
"android_fg": "./android.png",
|
||||||
|
"android_bg": "./background.png",
|
||||||
|
"android_fg_scale": 100,
|
||||||
|
"android_monochrome": "./monochrome.png"
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 997 KiB |
237
apps/tauri/logo.svg
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="90mm"
|
||||||
|
height="90mm"
|
||||||
|
viewBox="0 0 90 90"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xml:space="preserve"
|
||||||
|
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||||
|
sodipodi:docname="logo_raw.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#505050"
|
||||||
|
bordercolor="#ffffff"
|
||||||
|
borderopacity="1"
|
||||||
|
inkscape:showpageshadow="0"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pagecheckerboard="1"
|
||||||
|
inkscape:deskcolor="#505050"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
inkscape:zoom="0.99999999"
|
||||||
|
inkscape:cx="450"
|
||||||
|
inkscape:cy="291.5"
|
||||||
|
inkscape:window-width="1223"
|
||||||
|
inkscape:window-height="1369"
|
||||||
|
inkscape:window-x="26"
|
||||||
|
inkscape:window-y="23"
|
||||||
|
inkscape:window-maximized="0"
|
||||||
|
inkscape:current-layer="layer1" /><defs
|
||||||
|
id="defs1"><linearGradient
|
||||||
|
id="swatch15"
|
||||||
|
inkscape:swatch="solid"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop15" /></linearGradient><linearGradient
|
||||||
|
id="swatch10"
|
||||||
|
inkscape:swatch="solid"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop10" /></linearGradient><linearGradient
|
||||||
|
id="swatch3"
|
||||||
|
inkscape:swatch="solid"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3" /></linearGradient><linearGradient
|
||||||
|
id="swatch2"
|
||||||
|
inkscape:swatch="solid"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop2" /></linearGradient><linearGradient
|
||||||
|
id="swatch1"
|
||||||
|
inkscape:swatch="solid"><stop
|
||||||
|
style="stop-color:#031616;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop1" /></linearGradient><inkscape:path-effect
|
||||||
|
effect="fillet_chamfer"
|
||||||
|
id="path-effect2"
|
||||||
|
is_visible="true"
|
||||||
|
lpeversion="1"
|
||||||
|
nodesatellites_param="F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1"
|
||||||
|
radius="3"
|
||||||
|
unit="px"
|
||||||
|
method="auto"
|
||||||
|
mode="F"
|
||||||
|
chamfer_steps="1"
|
||||||
|
flexible="false"
|
||||||
|
use_knot_distance="true"
|
||||||
|
apply_no_radius="true"
|
||||||
|
apply_with_radius="true"
|
||||||
|
only_selected="false"
|
||||||
|
hide_knots="false" /><inkscape:path-effect
|
||||||
|
effect="bspline"
|
||||||
|
id="path-effect6"
|
||||||
|
is_visible="true"
|
||||||
|
lpeversion="1.3"
|
||||||
|
weight="33.333333"
|
||||||
|
steps="2"
|
||||||
|
helper_size="0"
|
||||||
|
apply_no_weight="true"
|
||||||
|
apply_with_weight="true"
|
||||||
|
only_selected="false"
|
||||||
|
uniform="false" /><inkscape:path-effect
|
||||||
|
effect="spiro"
|
||||||
|
id="path-effect5"
|
||||||
|
is_visible="true"
|
||||||
|
lpeversion="1" /><clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath1"><path
|
||||||
|
id="path3"
|
||||||
|
style="fill:#043b3c;stroke-width:0.320821"
|
||||||
|
inkscape:label="arrow"
|
||||||
|
d="m 244.98648,261.60864 -9.19072,26.71639 23.35523,-11.66051 c -4.69011,-5.05439 -10.12762,-9.5023 -14.16451,-15.05588 z m 40.12749,-74.6916 50.72412,18.76364 c 6.70121,39.09457 -10.8946,70.48625 -50.72412,85.29337 z m 0,0 -50.72412,18.76364 c -6.7012,39.09457 10.89461,70.48625 50.72412,85.29337 z"
|
||||||
|
sodipodi:nodetypes="cccccccccccc" /></clipPath><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#swatch1"
|
||||||
|
id="linearGradient15"
|
||||||
|
x1="232.99823"
|
||||||
|
y1="238.94554"
|
||||||
|
x2="337.22971"
|
||||||
|
y2="238.94554"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#swatch1"
|
||||||
|
id="linearGradient16"
|
||||||
|
x1="63.191292"
|
||||||
|
y1="148.5"
|
||||||
|
x2="146.82355"
|
||||||
|
y2="148.5"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#swatch1"
|
||||||
|
id="linearGradient17"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="232.99823"
|
||||||
|
y1="238.94554"
|
||||||
|
x2="337.22971"
|
||||||
|
y2="238.94554" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#swatch1"
|
||||||
|
id="linearGradient18"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="232.99823"
|
||||||
|
y1="238.94554"
|
||||||
|
x2="337.22971"
|
||||||
|
y2="238.94554" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#swatch1"
|
||||||
|
id="linearGradient19"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="232.99823"
|
||||||
|
y1="238.94554"
|
||||||
|
x2="337.22971"
|
||||||
|
y2="238.94554" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#swatch1"
|
||||||
|
id="linearGradient20"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="232.99823"
|
||||||
|
y1="238.94554"
|
||||||
|
x2="337.22971"
|
||||||
|
y2="238.94554" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#swatch1"
|
||||||
|
id="linearGradient21"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="232.99823"
|
||||||
|
y1="238.94554"
|
||||||
|
x2="337.22971"
|
||||||
|
y2="238.94554" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#swatch1"
|
||||||
|
id="linearGradient22"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="63.191292"
|
||||||
|
y1="148.5"
|
||||||
|
x2="146.82355"
|
||||||
|
y2="148.5" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#swatch1"
|
||||||
|
id="linearGradient23"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="63.191292"
|
||||||
|
y1="148.5"
|
||||||
|
x2="146.82355"
|
||||||
|
y2="148.5" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#swatch1"
|
||||||
|
id="linearGradient24"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="63.191292"
|
||||||
|
y1="148.5"
|
||||||
|
x2="146.82355"
|
||||||
|
y2="148.5" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#swatch1"
|
||||||
|
id="linearGradient25"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="63.191292"
|
||||||
|
y1="148.5"
|
||||||
|
x2="146.82355"
|
||||||
|
y2="148.5" /></defs><g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-62.941767,-104.13688)"><g
|
||||||
|
id="g2"
|
||||||
|
transform="matrix(0.78671048,0,0,0.83608014,-116.36038,-50.640745)"
|
||||||
|
inkscape:label="background"
|
||||||
|
style="display:inline;stroke:url(#linearGradient15);stroke-width:0.616508;stroke-dasharray:none"
|
||||||
|
clip-path="url(#clipPath1)"><path
|
||||||
|
id="rect1"
|
||||||
|
style="fill:#004b4a;stroke:url(#linearGradient17);stroke-width:0.616508;stroke-dasharray:none"
|
||||||
|
transform="rotate(-75)"
|
||||||
|
d="m -227.83395,273.59631 h 146.303341 v 24.39636 H -227.83395 Z" /><path
|
||||||
|
id="rect1-1"
|
||||||
|
style="fill:#00524e;stroke:url(#linearGradient18);stroke-width:0.616508;stroke-dasharray:none"
|
||||||
|
transform="rotate(-75)"
|
||||||
|
d="m -227.83395,297.99268 h 146.303341 v 24.39636 H -227.83395 Z" /><path
|
||||||
|
id="rect1-1-3"
|
||||||
|
style="fill:#006560;stroke:url(#linearGradient19);stroke-width:0.616508;stroke-dasharray:none"
|
||||||
|
transform="rotate(-75)"
|
||||||
|
d="m -227.83395,322.3891 h 146.303341 v 24.39636 H -227.83395 Z" /><path
|
||||||
|
id="rect1-1-3-6"
|
||||||
|
style="fill:#02857d;stroke:url(#linearGradient20);stroke-width:0.616508;stroke-dasharray:none"
|
||||||
|
transform="rotate(-75)"
|
||||||
|
d="m -227.83395,346.78546 h 146.303341 v 24.39636 H -227.83395 Z" /><path
|
||||||
|
id="rect1-1-3-6-2"
|
||||||
|
style="fill:#12a89e;stroke:url(#linearGradient21);stroke-width:0.616508;stroke-dasharray:none"
|
||||||
|
transform="rotate(-75)"
|
||||||
|
d="m -227.83395,371.18179 h 146.303341 v 24.39636 H -227.83395 Z" /></g><g
|
||||||
|
id="g3"
|
||||||
|
inkscape:label="foreground"
|
||||||
|
style="stroke:url(#linearGradient16);stroke-width:0.5;stroke-dasharray:none"
|
||||||
|
transform="translate(3.3653788,0.84760028)"><g
|
||||||
|
id="g1"
|
||||||
|
inkscape:label="outline"
|
||||||
|
style="stroke:url(#linearGradient23);stroke-width:0.5;stroke-dasharray:none"><path
|
||||||
|
id="path15-3-5"
|
||||||
|
style="opacity:0.352;fill:#000000;fill-opacity:1;stroke:url(#linearGradient22);stroke-width:0.5;stroke-dasharray:none"
|
||||||
|
inkscape:label="filler"
|
||||||
|
d="m -54.535435,65.443954 -38.869976,15.14533 c -2.902661,17.83718 0.143161,33.687786 8.852689,46.257116 l -5.823934,18.52239 15.016655,-8.2765 c 5.699447,4.98296 12.651108,9.14382 20.824566,12.34447 30.52177,-11.95203 44.00571,-37.29104 38.8705,-68.847476 z m -0.01808,2.85099 36.24585,14.11438 c 4.61111,37.589746 -14.17784,55.293626 -36.24585,64.158876 -21.698042,-8.5864 -40.914886,-26.53641 -36.245337,-64.158876 z"
|
||||||
|
transform="translate(159.54375,40.87617)" /></g><path
|
||||||
|
style="opacity:1;fill:#b8f8ff;fill-opacity:1;stroke:url(#linearGradient24);stroke-width:0.5;stroke-dasharray:none"
|
||||||
|
d="m 98.103726,126.47621 21.412544,0.16758 a 0.41189664,0.41189664 63.022469 0 1 0.33171,0.65164 l -8.29596,11.58914 a 0.41184923,0.41184923 63.019773 0 0 0.33171,0.65157 l 10.05254,0.0777 a 0.3104736,0.3104736 69.080154 0 1 0.20649,0.54017 l -30.495516,27.73194 a 0.15466019,0.15466019 36.691517 0 1 -0.243455,-0.18141 l 10.172421,-21.16912 a 0.50438266,0.50438266 58.099348 0 0 -0.44993,-0.72282 l -11.312564,-0.10524 a 0.52748832,0.52748832 56.926855 0 1 -0.479488,-0.73628 l 7.661544,-17.7722 a 1.1963277,1.1963277 146.88457 0 1 1.107954,-0.72269 z"
|
||||||
|
id="path2"
|
||||||
|
sodipodi:nodetypes="cccccccc"
|
||||||
|
inkscape:label="bolt" /><path
|
||||||
|
id="path1"
|
||||||
|
style="fill:#043b3c;stroke:url(#linearGradient25);stroke-width:0.5;stroke-dasharray:none"
|
||||||
|
inkscape:label="dark_outline"
|
||||||
|
d="m 104.99968,104.40528 -40.706558,15.90239 c -2.948156,18.16379 0.05742,34.31077 8.566402,47.3046 l -7.438306,22.73763 18.798853,-9.90998 c 5.772204,4.86637 12.68201,8.97564 20.779609,12.15481 31.96374,-12.54919 46.08489,-39.15399 40.70708,-72.28706 z m 0,1.91926 38.88755,15.13603 c 4.9985,39.45937 -15.37196,59.52899 -38.88755,68.8573 -7.461219,-2.95976 -14.605741,-7.00102 -20.723799,-12.39976 l -1.849499,0.95188 -13.267924,7.37991 5.196582,-16.46463 0.704866,-2.3027 c 0.02259,0.0331 0.04422,0.065 0,-5.2e-4 -0.02016,-0.0296 -0.04036,-0.0594 -0.01292,-0.0196 -7.707836,-11.18647 -11.48502,-25.8661 -8.934338,-46.00184 z m 5.2e-4,2.85151 -36.245852,14.11438 c -4.788427,29.4077 7.785188,53.02119 36.245852,64.15939 28.46066,-11.1382 41.03376,-34.75169 36.24533,-64.15939 z m 0,1.70377 34.62527,13.43381 c 4.45069,35.0227 -13.68688,52.83618 -34.62527,61.11564 -20.938395,-8.27946 -39.076477,-26.09294 -34.625796,-61.11564 z" /></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 10 KiB |
BIN
apps/tauri/monochrome.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
|
@ -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"
|
||||||
|
|
@ -25,12 +21,8 @@
|
||||||
"dev:mobile:raw": "tauri android dev",
|
"dev:mobile:raw": "tauri android dev",
|
||||||
"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": "if command -v nix >/dev/null 2>&1; then nix develop --command bun build:mobile:raw; else bun build: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",
|
||||||
"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.png",
|
|
||||||
"format": "bunx prettier --write .",
|
"format": "bunx prettier --write .",
|
||||||
"lint": "eslint src"
|
"lint": "eslint src"
|
||||||
},
|
},
|
||||||
|
|
@ -46,6 +38,7 @@
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2"
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@types/node": "^25.9.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
65
apps/tauri/render-version.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
// Config
|
||||||
|
const PLACEHOLDER_VERSION = "0.0.0";
|
||||||
|
|
||||||
|
const rootPackageJsonPath = path.resolve(__dirname, "../../package.json");
|
||||||
|
const cargoTomlPath = path.resolve(__dirname, "./src-tauri/Cargo.toml");
|
||||||
|
const tauriConfigPath = path.resolve(__dirname, "./src-tauri/tauri.conf.json");
|
||||||
|
|
||||||
|
// Args
|
||||||
|
const isUnrender = process.argv.includes("--unrender");
|
||||||
|
|
||||||
|
// Version Source
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(rootPackageJsonPath, "utf8"));
|
||||||
|
|
||||||
|
const packageVersion: string = packageJson.version;
|
||||||
|
|
||||||
|
if (!packageVersion && !isUnrender) {
|
||||||
|
throw new Error("No version found in package.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetVersion = isUnrender ? PLACEHOLDER_VERSION : packageVersion;
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
function updateCargoToml(content: string): string {
|
||||||
|
const regex = /^version\s*=\s*".*"$/m;
|
||||||
|
|
||||||
|
if (!regex.test(content)) {
|
||||||
|
throw new Error("Could not find version field in Cargo.toml");
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.replace(regex, `version = "${targetVersion}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTauriConfig(content: string): string {
|
||||||
|
const regex = /"version"\s*:\s*".*"/;
|
||||||
|
|
||||||
|
if (!regex.test(content)) {
|
||||||
|
throw new Error("Could not find version field in tauri.conf.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.replace(regex, `"version": "${targetVersion}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Cargo.toml
|
||||||
|
const cargoToml = fs.readFileSync(cargoTomlPath, "utf8");
|
||||||
|
|
||||||
|
const updatedCargoToml = updateCargoToml(cargoToml);
|
||||||
|
|
||||||
|
fs.writeFileSync(cargoTomlPath, updatedCargoToml, "utf8");
|
||||||
|
|
||||||
|
// Update tauri.conf.json
|
||||||
|
const tauriConfig = fs.readFileSync(tauriConfigPath, "utf8");
|
||||||
|
|
||||||
|
const updatedTauriConfig = updateTauriConfig(tauriConfig);
|
||||||
|
|
||||||
|
fs.writeFileSync(tauriConfigPath, updatedTauriConfig, "utf8");
|
||||||
|
|
||||||
|
// Finished
|
||||||
|
if (isUnrender) {
|
||||||
|
console.log(`Unrendered versions back to ${PLACEHOLDER_VERSION}`);
|
||||||
|
} else {
|
||||||
|
console.log(`Rendered version ${targetVersion}`);
|
||||||
|
}
|
||||||
2
apps/tauri/src-tauri/Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "tensamin"
|
name = "tensamin"
|
||||||
version = "0.1.0"
|
version = "0.0.0"
|
||||||
description = "Privacy focused messanger"
|
description = "Privacy focused messanger"
|
||||||
authors = ["methanium"]
|
authors = ["methanium"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 758 B |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 6 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 34 KiB |
|
|
@ -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>
|
||||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 1,001 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 6 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
|
|
@ -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}");
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Tensamin",
|
"productName": "Tensamin",
|
||||||
"version": "0.1.0",
|
"version": "0.0.0",
|
||||||
"mainBinaryName": "tensamin",
|
"mainBinaryName": "tensamin",
|
||||||
"identifier": "net.tensamin.client",
|
"identifier": "net.tensamin.client",
|
||||||
"build": {
|
"build": {
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"types": ["node"],
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
|
@ -8,5 +9,5 @@
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", "./render-version.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
"@tensamin/tauri": "workspace:*",
|
"@tensamin/tauri": "workspace:*",
|
||||||
"@tensamin/ttp": "workspace:*",
|
"@tensamin/ttp": "workspace:*",
|
||||||
"@tensamin/tauth": "workspace:*",
|
"@tensamin/tauth": "workspace:*",
|
||||||
|
"@tensamin/markdown": "workspace:*",
|
||||||
"@tensamin/ui": "*",
|
"@tensamin/ui": "*",
|
||||||
"@tensamin/user": "workspace:*",
|
"@tensamin/user": "workspace:*",
|
||||||
"@tensamin/notifications": "workspace:*",
|
"@tensamin/notifications": "workspace:*",
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 4.2 KiB |
|
|
@ -1,20 +1,58 @@
|
||||||
import type { User } from "@tensamin/user/context";
|
import type { User } from "@tensamin/user/context";
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from "@tensamin/ui";
|
import {
|
||||||
import { reduceDisplay } from "./utils";
|
Avatar,
|
||||||
|
AvatarImage,
|
||||||
|
AvatarFallback,
|
||||||
|
Tooltip,
|
||||||
|
TooltipTrigger,
|
||||||
|
TooltipContent,
|
||||||
|
} from "@tensamin/ui";
|
||||||
import { Card, CardHeader } from "@tensamin/ui";
|
import { Card, CardHeader } from "@tensamin/ui";
|
||||||
import { Skeleton } from "@tensamin/ui";
|
import { Skeleton } from "@tensamin/ui";
|
||||||
|
import { getStatusColor } from "@tensamin/shared/data";
|
||||||
|
|
||||||
export function Basic(props: { user: User }) {
|
export function Basic({
|
||||||
|
user,
|
||||||
|
extra,
|
||||||
|
}: {
|
||||||
|
user: User;
|
||||||
|
extra?: React.ReactNode;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card className="animate-in fade-in duration-300 rounded-2xl py-0 m-px">
|
<Card className="animate-in fade-in duration-300 rounded-2xl py-0 m-px">
|
||||||
<CardHeader className="flex flex-row gap-2.5 items-center justify-start p-2">
|
<CardHeader className="flex flex-row gap-2.5 items-center justify-start p-2">
|
||||||
|
<div className="relative shrink-0 overflow-visible">
|
||||||
<Avatar>
|
<Avatar>
|
||||||
<AvatarImage src={props.user.avatar} />
|
<AvatarImage src={user.avatar} />
|
||||||
<AvatarFallback>{reduceDisplay(props.user.display)}</AvatarFallback>
|
<AvatarFallback>
|
||||||
|
{user.display.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex flex-col gap-1 w-full items-start justify-center text-[15px]">
|
<Tooltip>
|
||||||
<p>{props.user.display}</p>
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<div className="absolute -bottom-0.5 -right-0.5 z-10 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-card">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: getStatusColor(user.online_status),
|
||||||
|
}}
|
||||||
|
className="h-2.25 w-2.25 rounded-full"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>
|
||||||
|
{user.online_status
|
||||||
|
.split("_")
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(" ")}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 w-full items-start justify-center text-[15px]">
|
||||||
|
<p>{user.display}</p>
|
||||||
|
</div>
|
||||||
|
<div className="pr-1">{extra}</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
34
apps/web/src/components/modals/profile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
/**
|
|
||||||
* Executes reduceDisplay.
|
|
||||||
* @param display Parameter display.
|
|
||||||
* @returns unknown.
|
|
||||||
*/
|
|
||||||
export function reduceDisplay(display: string) {
|
|
||||||
const words = display.split(" ");
|
|
||||||
if (words.length === 1) {
|
|
||||||
return display.slice(0, 2).toUpperCase();
|
|
||||||
} else {
|
|
||||||
return words[0].charAt(0).toUpperCase() + words[1].charAt(0).toUpperCase();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import { Button } from "@tensamin/ui";
|
import { Button, Popover, PopoverContent, PopoverTrigger } from "@tensamin/ui";
|
||||||
import { ArrowLeft, House, Phone, Settings, User } from "lucide-react";
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
House,
|
||||||
|
Phone,
|
||||||
|
Settings,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
import { useLocation, useNavigate, useSearch } from "@tanstack/react-router";
|
import { useLocation, useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
import { joinCall, useCall } from "@tensamin/call/store";
|
import { joinCall, useCall } from "@tensamin/call/store";
|
||||||
import Wrapper from "@tensamin/user/wrapper";
|
import Wrapper from "@tensamin/user/wrapper";
|
||||||
|
|
@ -10,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();
|
||||||
|
|
@ -23,10 +32,11 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
||||||
const currentCalls = calls.filter((call) =>
|
const currentCalls = calls.filter((call) =>
|
||||||
call.call_members.some((member) => member === id),
|
call.call_members.some((member) => member === id),
|
||||||
);
|
);
|
||||||
//const currentCalls = ["leck", "schleck", "und", "eck"];
|
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -63,7 +73,27 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
||||||
<Wrapper
|
<Wrapper
|
||||||
userId={id}
|
userId={id}
|
||||||
component={(user) => (
|
component={(user) => (
|
||||||
|
<>
|
||||||
|
<Popover open={userInfoOpen} onOpenChange={setUserInfoOpen}>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="px-0! text-foreground"
|
||||||
|
style={{
|
||||||
|
textDecorationLine: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<p className="font-medium text-md">{user?.display}</p>
|
<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" />}
|
||||||
/>
|
/>
|
||||||
|
|
@ -126,7 +156,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Controls className="mr-2 ring-border" />
|
<Controls className="mr-2" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Button } 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";
|
||||||
|
|
@ -83,25 +83,19 @@ function parseTuFileContent(rawFileContent: string): {
|
||||||
return { userId, privateKey, domain };
|
return { userId, privateKey, domain };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the login form for file upload and manual credential login.
|
|
||||||
* @returns Login form JSX.
|
|
||||||
*/
|
|
||||||
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 { save } = useStorage();
|
const { save } = useStorage();
|
||||||
|
|
||||||
/**
|
// Process dropped files
|
||||||
* Handles uploaded .tu files and stores resolved credentials.
|
const processDroppedFile = React.useCallback(
|
||||||
* @param event Change event from the hidden file input.
|
async (file: globalThis.File): Promise<void> => {
|
||||||
* @returns Promise that resolves when processing has finished.
|
|
||||||
*/
|
|
||||||
const handleFileInputChange = React.useCallback(
|
|
||||||
async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
|
||||||
try {
|
try {
|
||||||
const file = event.currentTarget.files?.[0];
|
if (!file.name.endsWith(".tu")) {
|
||||||
if (!file) {
|
toast("error", "Please upload a .tu file");
|
||||||
throw new Error("No file selected");
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = await file.text();
|
const raw = await file.text();
|
||||||
|
|
@ -123,13 +117,94 @@ export default function Form() {
|
||||||
[save],
|
[save],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle .tu files
|
||||||
|
const handleFileInputChange = React.useCallback(
|
||||||
|
async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||||
|
const file = event.currentTarget.files?.[0];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
toast("error", "No file selected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await processDroppedFile(file);
|
||||||
|
},
|
||||||
|
[processDroppedFile],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drag and drop listener
|
||||||
|
React.useEffect(() => {
|
||||||
|
let dragCounter = 0;
|
||||||
|
|
||||||
|
const handleDragEnter = (event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
dragCounter++;
|
||||||
|
|
||||||
|
if (event.dataTransfer?.items?.length) {
|
||||||
|
setIsDragging(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
dragCounter--;
|
||||||
|
|
||||||
|
if (dragCounter <= 0) {
|
||||||
|
setIsDragging(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.dropEffect = "copy";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = async (event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
dragCounter = 0;
|
||||||
|
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
const file = event.dataTransfer?.files?.[0];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
toast("error", "No file dropped");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await processDroppedFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("dragenter", handleDragEnter);
|
||||||
|
window.addEventListener("dragleave", handleDragLeave);
|
||||||
|
window.addEventListener("dragover", handleDragOver);
|
||||||
|
window.addEventListener("drop", handleDrop);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("dragenter", handleDragEnter);
|
||||||
|
window.removeEventListener("dragleave", handleDragLeave);
|
||||||
|
window.removeEventListener("dragover", handleDragOver);
|
||||||
|
window.removeEventListener("drop", handleDrop);
|
||||||
|
};
|
||||||
|
}, [processDroppedFile]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles username and private key login submission.
|
* Handles username and private key login submission.
|
||||||
* @param event Form submit event.
|
* @param event Form submit event.
|
||||||
* @returns Promise that resolves after login processing.
|
* @returns Promise that resolves after login processing.
|
||||||
*/
|
*/
|
||||||
const handleCredentialsSubmit = React.useCallback(
|
const handleCredentialsSubmit = React.useCallback(
|
||||||
async (event: React.SubmitEvent<HTMLFormElement>): Promise<void> => {
|
async (event: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const formData = new FormData(event.currentTarget);
|
const formData = new FormData(event.currentTarget);
|
||||||
|
|
@ -190,35 +265,36 @@ export default function Form() {
|
||||||
[save],
|
[save],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isTauriEnv = isTauri();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex md:flex-row flex-col gap-15">
|
<div className="relative flex md:flex-row flex-col gap-15">
|
||||||
{isTauriEnv ? (
|
{isTauri() && isMobile ? (
|
||||||
<>
|
<>
|
||||||
<QrCodeScanner
|
<QrCodeScanner
|
||||||
onData={async (data) => {
|
onData={async (data) => {
|
||||||
if (!data.startsWith("tensamin://tu::")) {
|
if (!data.startsWith("tensamin://tu::")) {
|
||||||
toast("error", "Invalid QR code");
|
toast("error", "Invalid QR code");
|
||||||
return;
|
return;
|
||||||
} else {
|
}
|
||||||
|
|
||||||
const decoded = data.replace("tensamin://tu::", "");
|
const decoded = data.replace("tensamin://tu::", "");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { userId, privateKey, domain } =
|
const { userId, privateKey, domain } =
|
||||||
parseTuFileContent(decoded);
|
parseTuFileContent(decoded);
|
||||||
|
|
||||||
await save("session_id", Date.now());
|
await save("session_id", Date.now());
|
||||||
await save("user_id", userId);
|
await save("user_id", userId);
|
||||||
await save("private_key", privateKey);
|
await save("private_key", privateKey);
|
||||||
|
|
||||||
if (domain) {
|
if (domain) {
|
||||||
await save("ttp_url", `https://${domain}/`);
|
await save("ttp_url", `https://${domain}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
location.href = "/";
|
location.href = "/";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(0, "login", "red", error);
|
log(0, "login", "red", error);
|
||||||
toast("error", "Failed to parse QR code data");
|
toast("error", "Failed to parse QR code data");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button onClick={() => uploadRef.current?.click()}>
|
<Button onClick={() => uploadRef.current?.click()}>
|
||||||
|
|
@ -228,10 +304,30 @@ export default function Form() {
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
onClick={() => uploadRef.current?.click()}
|
onClick={() => uploadRef.current?.click()}
|
||||||
className="flex flex-col gap-3 cursor-pointer w-55 aspect-square bg-input/13 hover:bg-input/30 transition-all duration-300 ease-in-out border-3 items-center justify-center rounded-lg"
|
className={cn(
|
||||||
|
"flex flex-col gap-3 cursor-pointer w-55 aspect-square",
|
||||||
|
"border-3 items-center justify-center rounded-lg",
|
||||||
|
"transition-all duration-300 ease-in-out",
|
||||||
|
"border-input",
|
||||||
|
"bg-input/13 hover:bg-input/30",
|
||||||
|
isDragging ? "animate-wiggle" : "",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<File className="text-foreground" size={27} />
|
<File
|
||||||
<p className="text-md">Select .tu file</p>
|
className={["transition-all duration-300 text-foreground"].join(
|
||||||
|
" ",
|
||||||
|
)}
|
||||||
|
size={27}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className={[
|
||||||
|
"text-md transition-all duration-300",
|
||||||
|
isDragging ? "font-medium" : "",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
Select .tu file
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,27 @@ import {
|
||||||
Sidebar as SidebarRoot,
|
Sidebar as SidebarRoot,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
|
useSidebar,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Input,
|
||||||
|
DialogClose,
|
||||||
|
Button,
|
||||||
|
Label,
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
} from "@tensamin/ui";
|
} from "@tensamin/ui";
|
||||||
import { isTauri } from "@tauri-apps/api/core";
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
import { useIsMobile } from "@tensamin/ui";
|
import { useIsMobile } from "@tensamin/ui";
|
||||||
|
|
@ -13,17 +34,171 @@ import { MobileNavbar } from "./navbar";
|
||||||
|
|
||||||
import SidebarBox from "@tensamin/call/sidebarBox";
|
import SidebarBox from "@tensamin/call/sidebarBox";
|
||||||
import { useShowMobileNavbar } from "@/routes/app/layout";
|
import { useShowMobileNavbar } from "@/routes/app/layout";
|
||||||
|
import { Ellipsis, Check } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { User } from "@tensamin/user/context";
|
||||||
|
import type z from "zod";
|
||||||
|
import { ttp } from "@tensamin/shared/data";
|
||||||
|
import { useTTP } from "@tensamin/ttp";
|
||||||
|
|
||||||
|
type OnlineStatus = z.infer<
|
||||||
|
typeof ttp.get_user_data.response.shape.online_status
|
||||||
|
>;
|
||||||
|
|
||||||
|
const onlineStatusLabels: Record<OnlineStatus, string> = {
|
||||||
|
user_online: "Online",
|
||||||
|
user_offline: "Offline",
|
||||||
|
user_dnd: "Do not disturb",
|
||||||
|
user_idle: "Idle",
|
||||||
|
user_wc: "Away",
|
||||||
|
user_borked: "Borked",
|
||||||
|
iota_offline: "Iota offline",
|
||||||
|
iota_online: "Iota online",
|
||||||
|
iota_borked: "Iota borked",
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatusDialog({
|
||||||
|
user,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
send,
|
||||||
|
draftStatus,
|
||||||
|
setDraftStatus,
|
||||||
|
draftOnlineStatus,
|
||||||
|
setDraftOnlineStatus,
|
||||||
|
errorMessage,
|
||||||
|
setErrorMessage,
|
||||||
|
saveSucceeded,
|
||||||
|
setSaveSucceeded,
|
||||||
|
}: {
|
||||||
|
user: User;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
send: ReturnType<typeof useTTP>["send"];
|
||||||
|
draftStatus: string;
|
||||||
|
setDraftStatus: (value: string) => void;
|
||||||
|
draftOnlineStatus: OnlineStatus;
|
||||||
|
setDraftOnlineStatus: (value: OnlineStatus) => void;
|
||||||
|
errorMessage: string;
|
||||||
|
setErrorMessage: (value: string) => void;
|
||||||
|
saveSucceeded: boolean;
|
||||||
|
setSaveSucceeded: (value: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (!nextOpen) {
|
||||||
|
setDraftStatus(user.status ?? "");
|
||||||
|
setDraftOnlineStatus(user.online_status);
|
||||||
|
setErrorMessage("");
|
||||||
|
setSaveSucceeded(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenChange(nextOpen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Update Status</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="user-status">Status message</Label>
|
||||||
|
<Input
|
||||||
|
id="user-status"
|
||||||
|
placeholder="Getting snacks..."
|
||||||
|
value={draftStatus}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSaveSucceeded(false);
|
||||||
|
setErrorMessage("");
|
||||||
|
setDraftStatus(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="user-online-status">Online status</Label>
|
||||||
|
<Select
|
||||||
|
value={draftOnlineStatus}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSaveSucceeded(false);
|
||||||
|
setErrorMessage("");
|
||||||
|
setDraftOnlineStatus(value ?? "user_online");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full" id="user-online-status">
|
||||||
|
<SelectValue placeholder="Online">
|
||||||
|
{onlineStatusLabels[draftOnlineStatus]}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="p-1">
|
||||||
|
<SelectItem value="user_online">Online</SelectItem>
|
||||||
|
<SelectItem value="user_offline">Offline</SelectItem>
|
||||||
|
<SelectItem value="user_idle">Idle</SelectItem>
|
||||||
|
<SelectItem value="user_dnd">Do not disturb</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{errorMessage && (
|
||||||
|
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose render={<Button variant="destructive">Cancel</Button>} />
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
const payload = {
|
||||||
|
...(draftStatus && { status: draftStatus }),
|
||||||
|
online_status: draftOnlineStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
const validation =
|
||||||
|
ttp.change_user_data.request.safeParse(payload);
|
||||||
|
|
||||||
|
if (!validation.success) {
|
||||||
|
setSaveSucceeded(false);
|
||||||
|
setErrorMessage(
|
||||||
|
validation.error.issues[0]?.message ?? "Invalid status data",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await send("change_user_data", validation.data);
|
||||||
|
setSaveSucceeded(true);
|
||||||
|
setErrorMessage("");
|
||||||
|
} catch (err) {
|
||||||
|
setSaveSucceeded(false);
|
||||||
|
setErrorMessage("Failed to update status: " + err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saveSucceeded ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Check className="size-4" />
|
||||||
|
Saved
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Save"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the conversation sidebar with account summary and conversation list.
|
|
||||||
* @returns Sidebar JSX.
|
|
||||||
*/
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const showMobileNavbar = useShowMobileNavbar();
|
const showMobileNavbar = useShowMobileNavbar();
|
||||||
|
const { openMobile, setOpenMobile } = useSidebar();
|
||||||
|
const [draftStatus, setDraftStatus] = useState("");
|
||||||
|
const [draftOnlineStatus, setDraftOnlineStatus] =
|
||||||
|
useState<OnlineStatus>("user_online");
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [statusErrorMessage, setStatusErrorMessage] = useState("");
|
||||||
|
const [statusSaveSucceeded, setStatusSaveSucceeded] = useState(false);
|
||||||
|
|
||||||
return (
|
const { send } = useTTP();
|
||||||
<SidebarRoot className="border-0!">
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
<SidebarContent
|
<SidebarContent
|
||||||
className={
|
className={
|
||||||
isTauri() && isMobile ? "pt-[env(safe-area-inset-top)]" : "pt-2"
|
isTauri() && isMobile ? "pt-[env(safe-area-inset-top)]" : "pt-2"
|
||||||
|
|
@ -34,7 +209,67 @@ export default function Sidebar() {
|
||||||
<Wrapper
|
<Wrapper
|
||||||
loading={<Loading />}
|
loading={<Loading />}
|
||||||
userId={"own"}
|
userId={"own"}
|
||||||
component={(user) => <Basic user={user} />}
|
component={(user) => (
|
||||||
|
<>
|
||||||
|
<Basic
|
||||||
|
user={user}
|
||||||
|
extra={
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Open profile menu"
|
||||||
|
className="cursor-pointer inline-flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
<Ellipsis />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuLabel>Profile</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setDraftStatus(user.status ?? "");
|
||||||
|
setDraftOnlineStatus(user.online_status);
|
||||||
|
setStatusErrorMessage("");
|
||||||
|
setStatusSaveSucceeded(false);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Set Status
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatusDialog
|
||||||
|
user={user}
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (!nextOpen) {
|
||||||
|
setDraftStatus(user.status ?? "");
|
||||||
|
setDraftOnlineStatus(user.online_status);
|
||||||
|
setStatusErrorMessage("");
|
||||||
|
setStatusSaveSucceeded(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDialogOpen(nextOpen);
|
||||||
|
}}
|
||||||
|
send={send}
|
||||||
|
draftStatus={draftStatus}
|
||||||
|
setDraftStatus={setDraftStatus}
|
||||||
|
draftOnlineStatus={draftOnlineStatus}
|
||||||
|
setDraftOnlineStatus={setDraftOnlineStatus}
|
||||||
|
errorMessage={statusErrorMessage}
|
||||||
|
setErrorMessage={setStatusErrorMessage}
|
||||||
|
saveSucceeded={statusSaveSucceeded}
|
||||||
|
setSaveSucceeded={setStatusSaveSucceeded}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
|
|
@ -52,6 +287,34 @@ export default function Sidebar() {
|
||||||
<SidebarBox />
|
<SidebarBox />
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
)}
|
)}
|
||||||
</SidebarRoot>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{openMobile && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 bg-black/10"
|
||||||
|
onClick={() => setOpenMobile(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar"
|
||||||
|
data-mobile="true"
|
||||||
|
className="fixed inset-y-0 left-0 z-50 w-screen bg-sidebar p-0 text-sidebar-foreground transition-[transform,opacity] duration-150 ease-linear"
|
||||||
|
style={{
|
||||||
|
transform: openMobile ? "translateX(0)" : "translateX(-100%)",
|
||||||
|
opacity: openMobile ? 1 : 0,
|
||||||
|
pointerEvents: openMobile ? "auto" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex h-full w-full flex-col">{content}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SidebarRoot className="border-0!">{content}</SidebarRoot>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||