diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f058ec09..2f95cb76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 36a14494..79ff824a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/Client/tauri-client/src-tauri/Cargo.lock b/Client/tauri-client/src-tauri/Cargo.lock index acc4179f..da6295a3 100644 --- a/Client/tauri-client/src-tauri/Cargo.lock +++ b/Client/tauri-client/src-tauri/Cargo.lock @@ -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" diff --git a/Client/tauri-client/src-tauri/Cargo.toml b/Client/tauri-client/src-tauri/Cargo.toml index 1a269778..f194bf9c 100644 --- a/Client/tauri-client/src-tauri/Cargo.toml +++ b/Client/tauri-client/src-tauri/Cargo.toml @@ -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" diff --git a/Client/tauri-client/src-tauri/src/credentials.rs b/Client/tauri-client/src-tauri/src/credentials.rs index 4b2cfbf9..b19261f8 100644 --- a/Client/tauri-client/src-tauri/src/credentials.rs +++ b/Client/tauri-client/src-tauri/src/credentials.rs @@ -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 { - 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 { - 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) -> Result<(), String> { +pub fn save_credential( + host: String, + username: String, + token: String, + password: Option, +) -> 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, 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, 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 = "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 = "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 = "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")); } } diff --git a/Client/tauri-client/src-tauri/src/ptt.rs b/Client/tauri-client/src-tauri/src/ptt.rs index 935c0cd3..28823d61 100644 --- a/Client/tauri-client/src-tauri/src/ptt.rs +++ b/Client/tauri-client/src-tauri/src/ptt.rs @@ -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 { + 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(app: AppHandle) { @@ -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); + } +} diff --git a/Client/tauri-client/src-tauri/tauri.conf.json b/Client/tauri-client/src-tauri/tauri.conf.json index 7c1cfd51..f8d679e5 100644 --- a/Client/tauri-client/src-tauri/tauri.conf.json +++ b/Client/tauri-client/src-tauri/tauri.conf.json @@ -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, diff --git a/Server/.dockerignore b/Server/.dockerignore new file mode 100644 index 00000000..45d81e73 --- /dev/null +++ b/Server/.dockerignore @@ -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 diff --git a/Server/Dockerfile b/Server/Dockerfile new file mode 100644 index 00000000..7508265a --- /dev/null +++ b/Server/Dockerfile @@ -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"]