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:
J3vb
2026-04-03 14:26:26 +02:00
parent 0a08fd72bd
commit 03969575e0
9 changed files with 870 additions and 166 deletions
+41 -2
View File
@@ -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:
+210 -3
View File
@@ -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
+115
View File
@@ -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"
+5 -1
View File
@@ -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"
+82 -139
View File
@@ -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"));
}
}
+339 -20
View File
@@ -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 0x410x5A, digits 0x300x39, 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 1254 (Windows virtual key codes).
/// Valid range: 0 (disabled) or 1254 (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);
}
}
+20 -1
View File
@@ -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,
+21
View File
@@ -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
+37
View File
@@ -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"]