mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
feat: Linux client port, ARM64 CI, and server Docker image
Client: - Replace Win32 Credential Manager with cross-platform keyring crate (Windows Credential Manager / Linux Secret Service / macOS Keychain) - Add Linux PTT support via device_query crate with VK-code-compatible mapping; thread-local DeviceState avoids repeated /dev/input/ opens - Add AppImage + deb bundle targets to tauri.conf.json with Linux metadata and deb runtime dependencies Cargo.toml: - Add keyring = "3" (all platforms) - Add device_query = "2" (Linux only, cfg guard) - Remove Win32_Security_Credentials feature (no longer needed) CI/CD: - Add ubuntu-22.04 and ubuntu-22.04-arm to tauri-build matrix - Fix Linux deps step condition: startsWith(matrix.os, 'ubuntu') - Add server Docker build verification job (build-only, no push) - Add release-client-linux (x86_64) and release-client-linux-arm64 jobs producing AppImage + deb artifacts - Add release-server-docker job pushing to ghcr.io on version tags - Update publish job to include all Linux and ARM64 artifacts Server: - Add multi-stage Dockerfile (golang:1.25-bookworm → distroless/static) - Non-root user (uid 65532), /app/data volume, port 8443 exposed - Add .dockerignore excluding binaries, data, and local config
This commit is contained in:
@@ -110,12 +110,37 @@ jobs:
|
||||
path: Client/tauri-client/coverage/
|
||||
retention-days: 7
|
||||
|
||||
server-docker-build:
|
||||
name: Server Docker Build (verify)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d8db1c2d1b0dc1 # v3.10.0
|
||||
|
||||
- name: Build image (no push)
|
||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
|
||||
with:
|
||||
context: Server/
|
||||
push: false
|
||||
build-args: VERSION=ci
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Full Tauri build only on PRs to main (expensive: ~15 min x2 multiplier)
|
||||
tauri-build:
|
||||
name: Tauri Full Build
|
||||
name: Tauri Full Build (${{ matrix.os }})
|
||||
needs: client-check
|
||||
if: github.event_name == 'pull_request' && github.base_ref == 'main'
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
- os: ubuntu-22.04
|
||||
- os: ubuntu-22.04-arm
|
||||
runs-on: ${{ matrix.os }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: Client/tauri-client/
|
||||
@@ -128,6 +153,20 @@ jobs:
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
|
||||
- name: Install Linux system dependencies
|
||||
if: startsWith(matrix.os, 'ubuntu')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libgtk-3-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
libsecret-1-dev \
|
||||
libasound2-dev \
|
||||
libssl-dev \
|
||||
patchelf \
|
||||
librsvg2-dev
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
with:
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
release-client:
|
||||
release-client-windows:
|
||||
name: Build Tauri (Windows)
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
@@ -58,6 +58,77 @@ jobs:
|
||||
name: windows-release-assets
|
||||
path: release-staging/
|
||||
|
||||
release-client-linux:
|
||||
name: Build Tauri (Linux)
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
|
||||
- name: Install Linux system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libgtk-3-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
libsecret-1-dev \
|
||||
libasound2-dev \
|
||||
libssl-dev \
|
||||
patchelf \
|
||||
librsvg2-dev
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: Client/tauri-client/src-tauri
|
||||
|
||||
- name: Install npm dependencies
|
||||
working-directory: Client/tauri-client
|
||||
run: npm ci
|
||||
|
||||
- name: Build Tauri app (AppImage + deb)
|
||||
working-directory: Client/tauri-client
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_KLIPY_API_KEY: ${{ secrets.VITE_KLIPY_API_KEY }}
|
||||
run: npm run tauri build -- --bundles appimage,deb
|
||||
|
||||
- name: Stage Linux release assets
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p linux-staging
|
||||
BUNDLE_DIR="Client/tauri-client/src-tauri/target/release/bundle"
|
||||
# AppImage
|
||||
APPIMAGE=$(find "$BUNDLE_DIR/appimage" -name "*.AppImage" ! -name "*.sig" | head -1)
|
||||
if [ -n "$APPIMAGE" ] && [ -f "$APPIMAGE" ]; then cp "$APPIMAGE" linux-staging/; fi
|
||||
APPIMAGE_SIG=$(find "$BUNDLE_DIR/appimage" -name "*.AppImage.sig" | head -1)
|
||||
if [ -n "$APPIMAGE_SIG" ] && [ -f "$APPIMAGE_SIG" ]; then cp "$APPIMAGE_SIG" linux-staging/; fi
|
||||
APPIMAGE_TAR=$(find "$BUNDLE_DIR/appimage" -name "*.AppImage.tar.gz" ! -name "*.sig" | head -1)
|
||||
if [ -n "$APPIMAGE_TAR" ] && [ -f "$APPIMAGE_TAR" ]; then cp "$APPIMAGE_TAR" linux-staging/; fi
|
||||
APPIMAGE_TAR_SIG=$(find "$BUNDLE_DIR/appimage" -name "*.AppImage.tar.gz.sig" | head -1)
|
||||
if [ -n "$APPIMAGE_TAR_SIG" ] && [ -f "$APPIMAGE_TAR_SIG" ]; then cp "$APPIMAGE_TAR_SIG" linux-staging/; fi
|
||||
# .deb
|
||||
DEB=$(find "$BUNDLE_DIR/deb" -name "*.deb" | head -1)
|
||||
if [ -n "$DEB" ] && [ -f "$DEB" ]; then cp "$DEB" linux-staging/; fi
|
||||
|
||||
- name: Upload Linux release assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-release-assets
|
||||
path: linux-staging/
|
||||
|
||||
release-server:
|
||||
name: Build server (${{ matrix.os }})
|
||||
strategy:
|
||||
@@ -115,9 +186,133 @@ jobs:
|
||||
name: ${{ matrix.artifact }}
|
||||
path: chatserver-linux-amd64.tar.gz
|
||||
|
||||
release-client-linux-arm64:
|
||||
name: Build Tauri (Linux ARM64)
|
||||
runs-on: ubuntu-22.04-arm
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
|
||||
- name: Install Linux system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libgtk-3-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
libsecret-1-dev \
|
||||
libasound2-dev \
|
||||
libssl-dev \
|
||||
patchelf \
|
||||
librsvg2-dev
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: Client/tauri-client/src-tauri
|
||||
|
||||
- name: Install npm dependencies
|
||||
working-directory: Client/tauri-client
|
||||
run: npm ci
|
||||
|
||||
- name: Build Tauri app (AppImage + deb)
|
||||
working-directory: Client/tauri-client
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_KLIPY_API_KEY: ${{ secrets.VITE_KLIPY_API_KEY }}
|
||||
run: npm run tauri build -- --bundles appimage,deb
|
||||
|
||||
- name: Stage Linux ARM64 release assets
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p linux-arm64-staging
|
||||
BUNDLE_DIR="Client/tauri-client/src-tauri/target/release/bundle"
|
||||
# AppImage (ensure arch is in filename)
|
||||
for f in "$BUNDLE_DIR"/appimage/*.AppImage; do
|
||||
[ -f "$f" ] || continue
|
||||
[[ "$f" == *.sig ]] && continue
|
||||
dest="linux-arm64-staging/$(basename "$f")"
|
||||
# Append _aarch64 if bundler omits arch from filename
|
||||
[[ "$(basename "$f")" == *aarch64* ]] || dest="${dest%.AppImage}_aarch64.AppImage"
|
||||
cp "$f" "$dest"
|
||||
done
|
||||
for f in "$BUNDLE_DIR"/appimage/*.AppImage.tar.gz; do
|
||||
[ -f "$f" ] && cp "$f" linux-arm64-staging/
|
||||
done
|
||||
for f in "$BUNDLE_DIR"/appimage/*.sig; do
|
||||
[ -f "$f" ] && cp "$f" linux-arm64-staging/
|
||||
done
|
||||
# .deb
|
||||
for f in "$BUNDLE_DIR"/deb/*.deb; do
|
||||
[ -f "$f" ] && cp "$f" linux-arm64-staging/
|
||||
done
|
||||
|
||||
- name: Upload Linux ARM64 release assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-arm64-release-assets
|
||||
path: linux-arm64-staging/
|
||||
|
||||
release-server-docker:
|
||||
name: Build & Push Server Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/owncord-server
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: Server/
|
||||
push: true
|
||||
build-args: VERSION=${{ env.VERSION }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
publish:
|
||||
name: Publish GitHub Release
|
||||
needs: [release-client, release-server]
|
||||
needs: [release-client-windows, release-client-linux, release-client-linux-arm64, release-server, release-server-docker]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -136,13 +331,25 @@ jobs:
|
||||
name: windows-release-assets
|
||||
path: windows
|
||||
|
||||
- name: Download Linux x86_64 client assets
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: linux-release-assets
|
||||
path: linux
|
||||
|
||||
- name: Download Linux ARM64 client assets
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: linux-arm64-release-assets
|
||||
path: linux
|
||||
|
||||
- name: Download Windows server binary
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: server-windows
|
||||
path: windows
|
||||
|
||||
- name: Download Linux archive
|
||||
- name: Download Linux server archive
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: server-linux
|
||||
|
||||
Generated
+115
@@ -955,6 +955,21 @@ version = "1.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
|
||||
|
||||
[[package]]
|
||||
name = "device_query"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bafa241a89a5edccff5057d0b85fbc083a781bd03d766c11a688331604980985"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"macos-accessibility-client",
|
||||
"pkg-config",
|
||||
"readkey",
|
||||
"readmouse",
|
||||
"windows 0.48.0",
|
||||
"x11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -2360,6 +2375,16 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
||||
dependencies = [
|
||||
"log",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kuchikiki"
|
||||
version = "0.8.8-speedreader"
|
||||
@@ -2499,6 +2524,16 @@ dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "macos-accessibility-client"
|
||||
version = "0.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edf7710fbff50c24124331760978fb9086d6de6288dcdb38b25a97f8b1bdebbb"
|
||||
dependencies = [
|
||||
"core-foundation 0.9.4",
|
||||
"core-foundation-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.14.1"
|
||||
@@ -2909,8 +2944,10 @@ dependencies = [
|
||||
name = "owncord-client"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"device_query",
|
||||
"env_logger",
|
||||
"futures-util",
|
||||
"keyring",
|
||||
"log",
|
||||
"ring",
|
||||
"rustls",
|
||||
@@ -3664,6 +3701,18 @@ version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||
|
||||
[[package]]
|
||||
name = "readkey"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a36870cefdfcff57edbc0fa62165f42dfd4e5a0d8965117c1ea84c5700e4450"
|
||||
|
||||
[[package]]
|
||||
name = "readmouse"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be105c72a1e6a5a1198acee3d5b506a15676b74a02ecd78060042a447f408d94"
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -6077,6 +6126,15 @@ dependencies = [
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.58.0"
|
||||
@@ -6351,6 +6409,21 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
@@ -6408,6 +6481,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -6426,6 +6505,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -6444,6 +6529,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -6474,6 +6565,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -6492,6 +6589,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -6510,6 +6613,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -6528,6 +6637,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
|
||||
@@ -38,6 +38,10 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std"
|
||||
ring = "0.17"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
keyring = "3"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.58", features = ["Win32_Security_Credentials", "Win32_Foundation", "Win32_UI_Input_KeyboardAndMouse"] }
|
||||
windows = { version = "0.58", features = ["Win32_UI_Input_KeyboardAndMouse"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
device_query = "2"
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
use keyring::Entry;
|
||||
use serde::Serialize;
|
||||
use std::ptr;
|
||||
use windows::core::{PCWSTR, PWSTR};
|
||||
use windows::Win32::Foundation::ERROR_NOT_FOUND;
|
||||
// CRED_PERSIST_ENTERPRISE scopes credentials per-user (roams with domain
|
||||
// profile). Previously CRED_PERSIST_LOCAL_MACHINE was used, which exposes
|
||||
// credentials to all users on shared machines.
|
||||
use windows::Win32::Security::Credentials::{
|
||||
CredDeleteW, CredFree, CredReadW, CredWriteW, CREDENTIALW, CRED_FLAGS,
|
||||
CRED_PERSIST_ENTERPRISE, CRED_TYPE_GENERIC,
|
||||
};
|
||||
|
||||
const SERVICE: &str = "com.owncord.client";
|
||||
|
||||
/// Data returned from `load_credential`.
|
||||
#[derive(Serialize, Clone)]
|
||||
@@ -31,32 +24,26 @@ impl std::fmt::Debug for CredentialData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the target name used in Windows Credential Manager.
|
||||
fn target_name(host: &str) -> Vec<u16> {
|
||||
let name = format!("OwnCord/{host}");
|
||||
name.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
/// Encode a Rust string as a null-terminated UTF-16 vector.
|
||||
fn to_wide(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Save a credential (username + token + optional password) to Windows
|
||||
/// Credential Manager.
|
||||
/// Save a credential (username + token + optional password) to the system
|
||||
/// credential store.
|
||||
///
|
||||
/// Target name: `OwnCord/{host}`
|
||||
/// Blob: JSON `{"username":"...","token":"...","password":"..."}`
|
||||
/// Credential key: service=`com.owncord.client`, account=`host`
|
||||
/// Secret: JSON `{"username":"...","token":"...","password":"..."}`
|
||||
///
|
||||
/// The password field is only included when the user checks "Remember
|
||||
/// password". Windows Credential Manager encrypts the blob at rest using
|
||||
/// DPAPI, tied to the logged-in Windows user — plaintext is never on disk.
|
||||
/// On Windows the secret is protected by DPAPI via Windows Credential Manager.
|
||||
/// On Linux it is stored in the Secret Service (GNOME Keyring / KWallet).
|
||||
/// On macOS it is stored in the system Keychain.
|
||||
#[tauri::command]
|
||||
pub fn save_credential(host: String, username: String, token: String, password: Option<String>) -> Result<(), String> {
|
||||
pub fn save_credential(
|
||||
host: String,
|
||||
username: String,
|
||||
token: String,
|
||||
password: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
if host.is_empty() {
|
||||
return Err("host must not be empty".into());
|
||||
}
|
||||
@@ -67,9 +54,6 @@ pub fn save_credential(host: String, username: String, token: String, password:
|
||||
return Err("username must not be empty".into());
|
||||
}
|
||||
|
||||
let target = target_name(&host);
|
||||
let wide_user = to_wide(&username);
|
||||
|
||||
let mut payload = serde_json::json!({
|
||||
"username": username,
|
||||
"token": token,
|
||||
@@ -77,32 +61,17 @@ pub fn save_credential(host: String, username: String, token: String, password:
|
||||
if let Some(ref pw) = password {
|
||||
payload["password"] = serde_json::Value::String(pw.clone());
|
||||
}
|
||||
let blob = payload.to_string().into_bytes();
|
||||
|
||||
let cred = CREDENTIALW {
|
||||
Flags: CRED_FLAGS(0),
|
||||
Type: CRED_TYPE_GENERIC,
|
||||
TargetName: PWSTR(target.as_ptr() as *mut u16),
|
||||
Comment: PWSTR::null(),
|
||||
LastWritten: Default::default(),
|
||||
CredentialBlobSize: blob.len() as u32,
|
||||
CredentialBlob: blob.as_ptr() as *mut u8,
|
||||
Persist: CRED_PERSIST_ENTERPRISE,
|
||||
AttributeCount: 0,
|
||||
Attributes: ptr::null_mut(),
|
||||
TargetAlias: PWSTR::null(),
|
||||
UserName: PWSTR(wide_user.as_ptr() as *mut u16),
|
||||
};
|
||||
|
||||
unsafe {
|
||||
CredWriteW(&cred, 0)
|
||||
.map_err(|e| format!("CredWriteW failed: {e}"))?;
|
||||
}
|
||||
let entry =
|
||||
Entry::new(SERVICE, &host).map_err(|e| format!("keyring entry error: {e}"))?;
|
||||
entry
|
||||
.set_password(&payload.to_string())
|
||||
.map_err(|e| format!("save_credential failed: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a credential from Windows Credential Manager.
|
||||
/// Load a credential from the system credential store.
|
||||
///
|
||||
/// Returns `None` when no credential exists for the given host.
|
||||
#[tauri::command]
|
||||
@@ -111,46 +80,15 @@ pub fn load_credential(host: String) -> Result<Option<CredentialData>, String> {
|
||||
return Err("host must not be empty".into());
|
||||
}
|
||||
|
||||
let target = target_name(&host);
|
||||
let mut pcred: *mut CREDENTIALW = ptr::null_mut();
|
||||
let entry =
|
||||
Entry::new(SERVICE, &host).map_err(|e| format!("keyring entry error: {e}"))?;
|
||||
|
||||
let read_result = unsafe {
|
||||
CredReadW(
|
||||
PCWSTR(target.as_ptr()),
|
||||
CRED_TYPE_GENERIC,
|
||||
0,
|
||||
&mut pcred,
|
||||
)
|
||||
let json_str = match entry.get_password() {
|
||||
Ok(s) => s,
|
||||
Err(keyring::Error::NoEntry) => return Ok(None),
|
||||
Err(e) => return Err(format!("load_credential failed: {e}")),
|
||||
};
|
||||
|
||||
match read_result {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
if e.code() == ERROR_NOT_FOUND.to_hresult() {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(format!("CredReadW failed: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: `pcred` is valid after a successful CredReadW call.
|
||||
// Copy the blob bytes and free immediately — CredFree must run even if
|
||||
// parsing fails, otherwise the credential memory leaks.
|
||||
let blob = unsafe {
|
||||
let cred = &*pcred;
|
||||
let bytes = std::slice::from_raw_parts(
|
||||
cred.CredentialBlob,
|
||||
cred.CredentialBlobSize as usize,
|
||||
)
|
||||
.to_vec();
|
||||
CredFree(pcred as *const std::ffi::c_void);
|
||||
bytes
|
||||
};
|
||||
|
||||
// Parse outside the unsafe block — CredFree has already been called.
|
||||
let json_str = String::from_utf8(blob)
|
||||
.map_err(|e| format!("credential blob is not valid UTF-8: {e}"))?;
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json_str)
|
||||
.map_err(|e| format!("credential blob is not valid JSON: {e}"))?;
|
||||
|
||||
@@ -172,32 +110,22 @@ pub fn load_credential(host: String) -> Result<Option<CredentialData>, String> {
|
||||
Ok(Some(CredentialData { username, token, password }))
|
||||
}
|
||||
|
||||
/// Delete a credential from Windows Credential Manager.
|
||||
/// Delete a credential from the system credential store.
|
||||
///
|
||||
/// Deleting a non-existent credential is not treated as an error.
|
||||
#[tauri::command]
|
||||
pub fn delete_credential(host: String) -> Result<(), String> {
|
||||
if host.is_empty() {
|
||||
return Err("host must not be empty".into());
|
||||
}
|
||||
|
||||
let target = target_name(&host);
|
||||
let entry =
|
||||
Entry::new(SERVICE, &host).map_err(|e| format!("keyring entry error: {e}"))?;
|
||||
|
||||
let delete_result = unsafe {
|
||||
CredDeleteW(
|
||||
PCWSTR(target.as_ptr()),
|
||||
CRED_TYPE_GENERIC,
|
||||
0,
|
||||
)
|
||||
};
|
||||
|
||||
match delete_result {
|
||||
match entry.delete_credential() {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
if e.code() == ERROR_NOT_FOUND.to_hresult() {
|
||||
// Deleting a non-existent credential is not an error.
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!("CredDeleteW failed: {e}"))
|
||||
}
|
||||
Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(format!("delete_credential failed: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,48 +138,63 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn target_name_encodes_host_as_utf16() {
|
||||
let result = target_name("localhost:8443");
|
||||
let expected: Vec<u16> = "OwnCord/localhost:8443"
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
assert_eq!(result, expected);
|
||||
fn save_credential_rejects_empty_host() {
|
||||
let result = save_credential("".into(), "user".into(), "tok".into(), None);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("host must not be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_name_empty_host() {
|
||||
let result = target_name("");
|
||||
let expected: Vec<u16> = "OwnCord/"
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
assert_eq!(result, expected);
|
||||
fn save_credential_rejects_empty_token() {
|
||||
let result = save_credential("host".into(), "user".into(), "".into(), None);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("token must not be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_wide_ascii() {
|
||||
let result = to_wide("hello");
|
||||
let expected: Vec<u16> = "hello"
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
assert_eq!(result, expected);
|
||||
// Last element must be null terminator
|
||||
assert_eq!(*result.last().unwrap(), 0u16);
|
||||
fn save_credential_rejects_empty_username() {
|
||||
let result = save_credential("host".into(), "".into(), "tok".into(), None);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("username must not be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_wide_empty_string() {
|
||||
let result = to_wide("");
|
||||
assert_eq!(result, vec![0u16]);
|
||||
fn load_credential_rejects_empty_host() {
|
||||
let result = load_credential("".into());
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("host must not be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_wide_unicode() {
|
||||
let result = to_wide("日本語");
|
||||
assert_eq!(*result.last().unwrap(), 0u16);
|
||||
// 3 CJK chars + null terminator = 4 elements
|
||||
assert_eq!(result.len(), 4);
|
||||
fn delete_credential_rejects_empty_host() {
|
||||
let result = delete_credential("".into());
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("host must not be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_data_debug_redacts_sensitive_fields() {
|
||||
let data = CredentialData {
|
||||
username: "alice".into(),
|
||||
token: "secret-token".into(),
|
||||
password: Some("hunter2".into()),
|
||||
};
|
||||
let debug = format!("{data:?}");
|
||||
assert!(debug.contains("alice"));
|
||||
assert!(!debug.contains("secret-token"));
|
||||
assert!(!debug.contains("hunter2"));
|
||||
assert!(debug.contains("[REDACTED]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_data_skips_password_in_json() {
|
||||
let data = CredentialData {
|
||||
username: "alice".into(),
|
||||
token: "tok".into(),
|
||||
password: Some("pw".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&data).unwrap();
|
||||
assert!(!json.contains("password"));
|
||||
assert!(!json.contains("pw"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
//! Push-to-Talk via GetAsyncKeyState polling.
|
||||
//! Push-to-Talk via key-state polling.
|
||||
//!
|
||||
//! Uses a 20ms polling loop to detect key press/release without consuming
|
||||
//! the keystroke — other applications and the chat input continue to
|
||||
//! receive the key normally.
|
||||
//!
|
||||
//! Key codes use Windows Virtual Key (VK) code values on all platforms:
|
||||
//! letters 0x41–0x5A, digits 0x30–0x39, Space 0x20, Enter 0x0D, etc.
|
||||
//! This ensures the stored integer is consistent on both Windows and Linux.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
|
||||
use std::time::Duration;
|
||||
@@ -13,7 +17,10 @@ static PTT_VKEY: AtomicI32 = AtomicI32::new(0);
|
||||
/// Whether the polling loop is running.
|
||||
static PTT_RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Check if a virtual key is currently held down (non-consuming).
|
||||
// ---------------------------------------------------------------------------
|
||||
// Platform-specific key detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_key_down(vk: i32) -> bool {
|
||||
let state =
|
||||
@@ -21,11 +28,222 @@ fn is_key_down(vk: i32) -> bool {
|
||||
(state as u16 & 0x8000) != 0
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn is_key_down(vk: i32) -> bool {
|
||||
use device_query::{DeviceQuery, DeviceState};
|
||||
// Cache DeviceState per thread — creating it on every call would open/close
|
||||
// /dev/input/ file descriptors every 20ms in the polling loop.
|
||||
thread_local! {
|
||||
static DEVICE_STATE: DeviceState = DeviceState::new();
|
||||
}
|
||||
let Some(keycode) = linux::vk_to_keycode(vk) else {
|
||||
return false;
|
||||
};
|
||||
DEVICE_STATE.with(|ds| ds.get_keys().contains(&keycode))
|
||||
}
|
||||
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
fn is_key_down(_vk: i32) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Linux key code mapping (VK ↔ device_query::Keycode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use device_query::Keycode;
|
||||
|
||||
/// Convert a device_query Keycode to its Windows-VK-equivalent integer.
|
||||
/// Returns 0 for keys that have no mapping (treated as "unknown").
|
||||
pub fn keycode_to_vk(key: &Keycode) -> i32 {
|
||||
match key {
|
||||
// Digits
|
||||
Keycode::Key0 => 0x30,
|
||||
Keycode::Key1 => 0x31,
|
||||
Keycode::Key2 => 0x32,
|
||||
Keycode::Key3 => 0x33,
|
||||
Keycode::Key4 => 0x34,
|
||||
Keycode::Key5 => 0x35,
|
||||
Keycode::Key6 => 0x36,
|
||||
Keycode::Key7 => 0x37,
|
||||
Keycode::Key8 => 0x38,
|
||||
Keycode::Key9 => 0x39,
|
||||
// Letters
|
||||
Keycode::A => 0x41,
|
||||
Keycode::B => 0x42,
|
||||
Keycode::C => 0x43,
|
||||
Keycode::D => 0x44,
|
||||
Keycode::E => 0x45,
|
||||
Keycode::F => 0x46,
|
||||
Keycode::G => 0x47,
|
||||
Keycode::H => 0x48,
|
||||
Keycode::I => 0x49,
|
||||
Keycode::J => 0x4A,
|
||||
Keycode::K => 0x4B,
|
||||
Keycode::L => 0x4C,
|
||||
Keycode::M => 0x4D,
|
||||
Keycode::N => 0x4E,
|
||||
Keycode::O => 0x4F,
|
||||
Keycode::P => 0x50,
|
||||
Keycode::Q => 0x51,
|
||||
Keycode::R => 0x52,
|
||||
Keycode::S => 0x53,
|
||||
Keycode::T => 0x54,
|
||||
Keycode::U => 0x55,
|
||||
Keycode::V => 0x56,
|
||||
Keycode::W => 0x57,
|
||||
Keycode::X => 0x58,
|
||||
Keycode::Y => 0x59,
|
||||
Keycode::Z => 0x5A,
|
||||
// Control keys
|
||||
Keycode::Backspace => 0x08,
|
||||
Keycode::Tab => 0x09,
|
||||
Keycode::Return => 0x0D,
|
||||
Keycode::Escape => 0x1B,
|
||||
Keycode::Space => 0x20,
|
||||
Keycode::PageUp => 0x21,
|
||||
Keycode::PageDown => 0x22,
|
||||
Keycode::End => 0x23,
|
||||
Keycode::Home => 0x24,
|
||||
Keycode::Left => 0x25,
|
||||
Keycode::Up => 0x26,
|
||||
Keycode::Right => 0x27,
|
||||
Keycode::Down => 0x28,
|
||||
Keycode::Insert => 0x2D,
|
||||
Keycode::Delete => 0x2E,
|
||||
// Numpad
|
||||
Keycode::Numpad0 => 0x60,
|
||||
Keycode::Numpad1 => 0x61,
|
||||
Keycode::Numpad2 => 0x62,
|
||||
Keycode::Numpad3 => 0x63,
|
||||
Keycode::Numpad4 => 0x64,
|
||||
Keycode::Numpad5 => 0x65,
|
||||
Keycode::Numpad6 => 0x66,
|
||||
Keycode::Numpad7 => 0x67,
|
||||
Keycode::Numpad8 => 0x68,
|
||||
Keycode::Numpad9 => 0x69,
|
||||
// Function keys
|
||||
Keycode::F1 => 0x70,
|
||||
Keycode::F2 => 0x71,
|
||||
Keycode::F3 => 0x72,
|
||||
Keycode::F4 => 0x73,
|
||||
Keycode::F5 => 0x74,
|
||||
Keycode::F6 => 0x75,
|
||||
Keycode::F7 => 0x76,
|
||||
Keycode::F8 => 0x77,
|
||||
Keycode::F9 => 0x78,
|
||||
Keycode::F10 => 0x79,
|
||||
Keycode::F11 => 0x7A,
|
||||
Keycode::F12 => 0x7B,
|
||||
// Lock keys
|
||||
Keycode::CapsLock => 0x14,
|
||||
Keycode::NumLock => 0x90,
|
||||
Keycode::ScrollLock => 0x91,
|
||||
// Modifier keys (included so ptt_listen_for_key can skip them)
|
||||
Keycode::LShift | Keycode::RShift => 0x10,
|
||||
Keycode::LControl | Keycode::RControl => 0x11,
|
||||
Keycode::LAlt | Keycode::RAlt => 0x12,
|
||||
Keycode::Meta => 0x5B,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a VK-equivalent integer back to a device_query Keycode.
|
||||
/// Returns `None` for unknown codes.
|
||||
pub fn vk_to_keycode(vk: i32) -> Option<Keycode> {
|
||||
match vk {
|
||||
0x30 => Some(Keycode::Key0),
|
||||
0x31 => Some(Keycode::Key1),
|
||||
0x32 => Some(Keycode::Key2),
|
||||
0x33 => Some(Keycode::Key3),
|
||||
0x34 => Some(Keycode::Key4),
|
||||
0x35 => Some(Keycode::Key5),
|
||||
0x36 => Some(Keycode::Key6),
|
||||
0x37 => Some(Keycode::Key7),
|
||||
0x38 => Some(Keycode::Key8),
|
||||
0x39 => Some(Keycode::Key9),
|
||||
0x41 => Some(Keycode::A),
|
||||
0x42 => Some(Keycode::B),
|
||||
0x43 => Some(Keycode::C),
|
||||
0x44 => Some(Keycode::D),
|
||||
0x45 => Some(Keycode::E),
|
||||
0x46 => Some(Keycode::F),
|
||||
0x47 => Some(Keycode::G),
|
||||
0x48 => Some(Keycode::H),
|
||||
0x49 => Some(Keycode::I),
|
||||
0x4A => Some(Keycode::J),
|
||||
0x4B => Some(Keycode::K),
|
||||
0x4C => Some(Keycode::L),
|
||||
0x4D => Some(Keycode::M),
|
||||
0x4E => Some(Keycode::N),
|
||||
0x4F => Some(Keycode::O),
|
||||
0x50 => Some(Keycode::P),
|
||||
0x51 => Some(Keycode::Q),
|
||||
0x52 => Some(Keycode::R),
|
||||
0x53 => Some(Keycode::S),
|
||||
0x54 => Some(Keycode::T),
|
||||
0x55 => Some(Keycode::U),
|
||||
0x56 => Some(Keycode::V),
|
||||
0x57 => Some(Keycode::W),
|
||||
0x58 => Some(Keycode::X),
|
||||
0x59 => Some(Keycode::Y),
|
||||
0x5A => Some(Keycode::Z),
|
||||
0x08 => Some(Keycode::Backspace),
|
||||
0x09 => Some(Keycode::Tab),
|
||||
0x0D => Some(Keycode::Return),
|
||||
0x1B => Some(Keycode::Escape),
|
||||
0x20 => Some(Keycode::Space),
|
||||
0x21 => Some(Keycode::PageUp),
|
||||
0x22 => Some(Keycode::PageDown),
|
||||
0x23 => Some(Keycode::End),
|
||||
0x24 => Some(Keycode::Home),
|
||||
0x25 => Some(Keycode::Left),
|
||||
0x26 => Some(Keycode::Up),
|
||||
0x27 => Some(Keycode::Right),
|
||||
0x28 => Some(Keycode::Down),
|
||||
0x2D => Some(Keycode::Insert),
|
||||
0x2E => Some(Keycode::Delete),
|
||||
0x60 => Some(Keycode::Numpad0),
|
||||
0x61 => Some(Keycode::Numpad1),
|
||||
0x62 => Some(Keycode::Numpad2),
|
||||
0x63 => Some(Keycode::Numpad3),
|
||||
0x64 => Some(Keycode::Numpad4),
|
||||
0x65 => Some(Keycode::Numpad5),
|
||||
0x66 => Some(Keycode::Numpad6),
|
||||
0x67 => Some(Keycode::Numpad7),
|
||||
0x68 => Some(Keycode::Numpad8),
|
||||
0x69 => Some(Keycode::Numpad9),
|
||||
0x70 => Some(Keycode::F1),
|
||||
0x71 => Some(Keycode::F2),
|
||||
0x72 => Some(Keycode::F3),
|
||||
0x73 => Some(Keycode::F4),
|
||||
0x74 => Some(Keycode::F5),
|
||||
0x75 => Some(Keycode::F6),
|
||||
0x76 => Some(Keycode::F7),
|
||||
0x77 => Some(Keycode::F8),
|
||||
0x78 => Some(Keycode::F9),
|
||||
0x79 => Some(Keycode::F10),
|
||||
0x7A => Some(Keycode::F11),
|
||||
0x7B => Some(Keycode::F12),
|
||||
0x14 => Some(Keycode::CapsLock),
|
||||
0x90 => Some(Keycode::NumLock),
|
||||
0x91 => Some(Keycode::ScrollLock),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Modifier VK codes to skip in ptt_listen_for_key.
|
||||
pub fn is_modifier_vk(vk: i32) -> bool {
|
||||
matches!(vk, 0x10 | 0x11 | 0x12 | 0x5B | 0x5C)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Start the PTT polling loop. Emits `ptt-state` (bool) events.
|
||||
#[tauri::command]
|
||||
pub fn ptt_start<R: Runtime>(app: AppHandle<R>) {
|
||||
@@ -57,7 +275,7 @@ pub fn ptt_stop() {
|
||||
}
|
||||
|
||||
/// Set the PTT virtual key code. Pass 0 to disable.
|
||||
/// Valid range: 0 (disabled) or 1–254 (Windows virtual key codes).
|
||||
/// Valid range: 0 (disabled) or 1–254 (VK-equivalent key codes).
|
||||
#[tauri::command]
|
||||
pub fn ptt_set_key(vk_code: i32) -> Result<(), String> {
|
||||
if vk_code != 0 && !(1..=254).contains(&vk_code) {
|
||||
@@ -73,35 +291,136 @@ pub fn ptt_get_key() -> i32 {
|
||||
PTT_VKEY.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Wait for the user to press any non-modifier key and return its VK code.
|
||||
/// Used by the keybind capture UI. Times out after 10 seconds and returns 0
|
||||
/// to avoid blocking a thread indefinitely if the user navigates away.
|
||||
/// Wait for the user to press any non-modifier key and return its VK-equivalent code.
|
||||
/// Used by the keybind capture UI. Times out after 10 seconds and returns 0.
|
||||
/// Runs on a dedicated thread to avoid blocking the Tauri async thread pool.
|
||||
#[tauri::command]
|
||||
pub async fn ptt_listen_for_key() -> i32 {
|
||||
tokio::task::spawn_blocking(|| {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use device_query::{DeviceQuery, DeviceState};
|
||||
let device_state = DeviceState::new();
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
|
||||
while std::time::Instant::now() < deadline {
|
||||
for vk in 1..=254i32 {
|
||||
// Skip modifier keys
|
||||
if matches!(vk, 0x10 | 0x11 | 0x12 | 0x5B | 0x5C) {
|
||||
continue;
|
||||
}
|
||||
if is_key_down(vk) {
|
||||
// Wait for release (with its own timeout)
|
||||
let release_deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while is_key_down(vk) && std::time::Instant::now() < release_deadline {
|
||||
while std::time::Instant::now() < deadline {
|
||||
for key in device_state.get_keys() {
|
||||
let vk = linux::keycode_to_vk(&key);
|
||||
if vk == 0 || linux::is_modifier_vk(vk) {
|
||||
continue;
|
||||
}
|
||||
// Wait for key release (with its own timeout)
|
||||
let release_deadline =
|
||||
std::time::Instant::now() + Duration::from_secs(5);
|
||||
while device_state.get_keys().contains(&key)
|
||||
&& std::time::Instant::now() < release_deadline
|
||||
{
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
return vk;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
0
|
||||
}
|
||||
|
||||
0 // timed out — no key pressed
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
|
||||
while std::time::Instant::now() < deadline {
|
||||
for vk in 1..=254i32 {
|
||||
// Skip modifier keys
|
||||
if matches!(vk, 0x10 | 0x11 | 0x12 | 0x5B | 0x5C) {
|
||||
continue;
|
||||
}
|
||||
if is_key_down(vk) {
|
||||
let release_deadline =
|
||||
std::time::Instant::now() + Duration::from_secs(5);
|
||||
while is_key_down(vk) && std::time::Instant::now() < release_deadline {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
return vk;
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
{
|
||||
0 // unsupported platform
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ptt_set_key_accepts_valid_codes() {
|
||||
assert!(ptt_set_key(0).is_ok());
|
||||
assert!(ptt_set_key(1).is_ok());
|
||||
assert!(ptt_set_key(0x20).is_ok()); // Space
|
||||
assert!(ptt_set_key(0x41).is_ok()); // A
|
||||
assert!(ptt_set_key(254).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ptt_set_key_rejects_invalid_codes() {
|
||||
assert!(ptt_set_key(-1).is_err());
|
||||
assert!(ptt_set_key(255).is_err());
|
||||
assert!(ptt_set_key(300).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ptt_get_key_reflects_set_key() {
|
||||
ptt_set_key(0x41).unwrap();
|
||||
assert_eq!(ptt_get_key(), 0x41);
|
||||
ptt_set_key(0).unwrap();
|
||||
assert_eq!(ptt_get_key(), 0);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_keycode_round_trips_for_common_keys() {
|
||||
use super::linux::{keycode_to_vk, vk_to_keycode};
|
||||
use device_query::Keycode;
|
||||
|
||||
let cases = [
|
||||
(Keycode::A, 0x41),
|
||||
(Keycode::Z, 0x5A),
|
||||
(Keycode::Key0, 0x30),
|
||||
(Keycode::Key9, 0x39),
|
||||
(Keycode::Space, 0x20),
|
||||
(Keycode::Return, 0x0D),
|
||||
(Keycode::F1, 0x70),
|
||||
(Keycode::F12, 0x7B),
|
||||
];
|
||||
|
||||
for (keycode, vk) in cases {
|
||||
assert_eq!(keycode_to_vk(&keycode), vk, "keycode_to_vk failed for {keycode:?}");
|
||||
assert_eq!(
|
||||
vk_to_keycode(vk),
|
||||
Some(keycode.clone()),
|
||||
"vk_to_keycode failed for vk={vk:#04x}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_unknown_vk_returns_none() {
|
||||
use super::linux::vk_to_keycode;
|
||||
assert_eq!(vk_to_keycode(0xFF), None);
|
||||
assert_eq!(vk_to_keycode(0), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,13 +31,32 @@
|
||||
"active": true,
|
||||
"createUpdaterArtifacts": "v1Compatible",
|
||||
"targets": [
|
||||
"nsis"
|
||||
"nsis",
|
||||
"appimage",
|
||||
"deb"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"linux": {
|
||||
"deb": {
|
||||
"depends": [
|
||||
"libwebkit2gtk-4.1-0",
|
||||
"libgtk-3-0",
|
||||
"libayatana-appindicator3-1",
|
||||
"libsecret-1-0"
|
||||
]
|
||||
},
|
||||
"appimage": {
|
||||
"bundleMediaFramework": true
|
||||
}
|
||||
},
|
||||
"category": "Network",
|
||||
"shortDescription": "Self-hosted chat platform",
|
||||
"longDescription": "OwnCord is a self-hosted voice and text chat platform with end-to-end encryption and full media support.",
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"displayLanguageSelector": false,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Compiled binaries
|
||||
*.exe
|
||||
chatserver
|
||||
owncord-server.exe
|
||||
|
||||
# Data & uploads (user-provided at runtime via volume)
|
||||
data/
|
||||
|
||||
# Coverage and test output
|
||||
*.out
|
||||
*.prof
|
||||
|
||||
# Local config (users provide their own via volume mount or env)
|
||||
config.yaml
|
||||
|
||||
# Scripts (not needed in image)
|
||||
scripts/
|
||||
|
||||
# Git metadata
|
||||
.git
|
||||
.gitignore
|
||||
@@ -0,0 +1,37 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ─── Build stage ────────────────────────────────────────────────────────────
|
||||
FROM golang:1.25-bookworm AS builder
|
||||
|
||||
ARG VERSION=dev
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-o /chatserver \
|
||||
-ldflags "-s -w -X main.version=${VERSION}" \
|
||||
.
|
||||
|
||||
# ─── Final stage ─────────────────────────────────────────────────────────────
|
||||
# gcr.io/distroless/static-debian12: no shell, includes CA certs (needed for
|
||||
# TLS/ACME) and timezone data. Attack surface is minimal.
|
||||
FROM gcr.io/distroless/static-debian12
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /chatserver /chatserver
|
||||
|
||||
# /app/data is the default data_dir (SQLite DB + uploads).
|
||||
# Mount a named volume here to persist data across container restarts.
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
# Server listens on this port by default (configurable via config.yaml).
|
||||
EXPOSE 8443
|
||||
|
||||
# Run as non-root (distroless provides uid 65532 = "nonroot").
|
||||
USER 65532:65532
|
||||
|
||||
ENTRYPOINT ["/chatserver"]
|
||||
Reference in New Issue
Block a user