diff --git a/.github/workflows/linux-package.yml b/.github/workflows/linux-package.yml index fb302ca..ebc5634 100644 --- a/.github/workflows/linux-package.yml +++ b/.github/workflows/linux-package.yml @@ -28,7 +28,7 @@ jobs: - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable with: - toolchain: "1.95" + toolchain: "1.96.1" components: clippy,rustfmt - name: Ensure components @@ -195,6 +195,195 @@ jobs: path: | next_tablet_driver_*.deb + build-package-arm64: + name: Build Linux Release (ARM64) + runs-on: ubuntu-24.04-arm + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: linux-arm64-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + linux-arm64-cargo- + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.96.1" + components: clippy,rustfmt + + - name: Ensure components + run: | + rustup component add rustfmt clippy || true + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libudev-dev build-essential \ + libgtk-3-dev libglib2.0-dev libgdk-pixbuf2.0-dev libpango1.0-dev libcairo2-dev libatk1.0-dev \ + libx11-dev libxrandr-dev libxfixes-dev libxrender-dev libx11-xcb-dev libxkbcommon-dev \ + libgl1-mesa-dev libegl1-mesa-dev libgles2-mesa-dev libfuse2 libxdo-dev + + - name: Build release (glibc) + run: cargo build --workspace --release --all-features + env: + POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} + + - name: Run tests (native) + run: cargo test --workspace --all-features --verbose + + - name: Create package tarball + run: | + mkdir -p artifacts + BIN=target/release/next_tablet_driver + if [ -f "$BIN" ]; then + cp "$BIN" artifacts/ + chmod +x artifacts/next_tablet_driver + else + echo "Binary not found: $BIN" && exit 1 + fi + if [ -d "resources" ]; then + cp -r resources artifacts/resources || true + fi + tar -czf next_tablet_driver-linux-arm64-${{ github.sha }}.tar.gz -C artifacts . + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: next_tablet_driver-linux-arm64-${{ github.sha }} + path: next_tablet_driver-linux-arm64-${{ github.sha }}.tar.gz + + appimage-deb-arm64: + name: Package AppImage and .deb (ARM64) + needs: build-package-arm64 + runs-on: ubuntu-24.04-arm + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: next_tablet_driver-linux-arm64-${{ github.sha }} + path: ./downloaded_artifact + + - name: Prepare AppDir and copy files + run: | + mkdir -p AppDir/usr/bin + mkdir -p AppDir/usr/share/applications + mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps + mkdir -p pkg_workdir + if [ -f downloaded_artifact/next_tablet_driver-linux-arm64-${{ github.sha }}.tar.gz ]; then + tar -xzf downloaded_artifact/next_tablet_driver-linux-arm64-${{ github.sha }}.tar.gz -C ./pkg_workdir || true + fi + if [ -f pkg_workdir/next_tablet_driver ]; then + cp pkg_workdir/next_tablet_driver AppDir/usr/bin/next_tablet_driver + elif [ -f target/release/next_tablet_driver ]; then + cp target/release/next_tablet_driver AppDir/usr/bin/next_tablet_driver + elif [ -f downloaded_artifact/next_tablet_driver ]; then + cp downloaded_artifact/next_tablet_driver AppDir/usr/bin/next_tablet_driver + else + echo "Binary not found, aborting" && exit 1 + fi + chmod +x AppDir/usr/bin/next_tablet_driver + + # Copy icon if present + if [ -f pkg_workdir/resources/icon.png ]; then + cp pkg_workdir/resources/icon.png AppDir/usr/share/icons/hicolor/256x256/apps/next_tablet_driver.png + elif [ -f resources/icon.png ]; then + cp resources/icon.png AppDir/usr/share/icons/hicolor/256x256/apps/next_tablet_driver.png + else + echo "Warning: icon not found; AppImage may not include an icon" + fi + + # Create desktop file + printf '%s\n' '[Desktop Entry]' 'Name=NextTabletDriver' 'Exec=next_tablet_driver' 'Icon=next_tablet_driver' 'Type=Application' 'Categories=Utility;Graphics;' > AppDir/usr/share/applications/next_tablet_driver.desktop + + - name: Install runtime dependencies for linuxdeploy + run: | + sudo apt-get update + sudo apt-get install -y patchelf libfuse2 wget unzip + + - name: Download linuxdeploy and plugin + run: | + curl -sL -o linuxdeploy-aarch64.AppImage "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-aarch64.AppImage" + curl -sL -o linuxdeploy-plugin-appimage-aarch64.AppImage "https://github.com/linuxdeploy/linuxdeploy-plugin-appimage/releases/download/continuous/linuxdeploy-plugin-appimage-aarch64.AppImage" + chmod a+x linuxdeploy-*.AppImage + + - name: Create AppImage + env: + APPIMAGE_EXTRACT_AND_RUN: "1" + run: | + ./linuxdeploy-aarch64.AppImage --appdir AppDir -d AppDir/usr/share/applications/next_tablet_driver.desktop -i AppDir/usr/share/icons/hicolor/256x256/apps/next_tablet_driver.png --output appimage || true + # Find the generated AppImage + ls -la + APPIMAGE=$(ls -1 *.AppImage 2>/dev/null | head -n 1 || true) + if [ -z "$APPIMAGE" ]; then + echo "AppImage creation failed or no AppImage generated" && exit 1 + fi + echo "Created AppImage: $APPIMAGE" + + - name: Create .deb package (wraps AppImage) + run: | + set -e + APPIMAGE=$(ls -1 *.AppImage | head -n 1) + if [ -z "$APPIMAGE" ]; then + echo "AppImage not found, cannot build deb" && exit 1 + fi + mkdir -p deb_pkg/DEBIAN + mkdir -p deb_pkg/opt/next_tablet_driver + mkdir -p deb_pkg/usr/bin + + cp "$APPIMAGE" deb_pkg/opt/next_tablet_driver/next_tablet_driver.AppImage + chmod +x deb_pkg/opt/next_tablet_driver/next_tablet_driver.AppImage + + cat > deb_pkg/usr/bin/next_tablet_driver << 'WRAPPER' + #!/bin/sh + exec /opt/next_tablet_driver/next_tablet_driver.AppImage "$@" + WRAPPER + chmod +x deb_pkg/usr/bin/next_tablet_driver + + VERSION=$(sed -n 's/^version *= *"\(.*\)"/\1/p' Cargo.toml | head -n1 | tr -d '[:space:]') + if [ -z "$VERSION" ]; then + VERSION="${GITHUB_SHA}" + fi + + cat > deb_pkg/DEBIAN/control << EOF + Package: next-tablet-driver + Version: ${VERSION} + Section: utils + Priority: optional + Architecture: arm64 + Maintainer: iSweat + Description: NextTabletDriver portable Linux AppImage + EOF + + DEB_NAME="next_tablet_driver_${VERSION}_arm64.deb" + dpkg-deb --build deb_pkg "$DEB_NAME" + + - name: Upload AppImage artifact + uses: actions/upload-artifact@v4 + with: + name: next_tablet_driver-linux-appimage-arm64-${{ github.sha }} + path: | + *.AppImage + + - name: Upload .deb artifact + uses: actions/upload-artifact@v4 + with: + name: next_tablet_driver-linux-deb-arm64-${{ github.sha }} + path: | + next_tablet_driver_*.deb + musl-build: name: Best-effort MUSL static build (optional) runs-on: ubuntu-latest @@ -206,7 +395,7 @@ jobs: - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable with: - toolchain: "1.95" + toolchain: "1.96.1" components: clippy,rustfmt - name: Ensure components diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 565906e..87da3d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: - toolchain: 1.95.0 + toolchain: 1.96.1 - name: Rust cache uses: swatinem/rust-cache@v2 @@ -36,16 +36,33 @@ jobs: env: POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} - - name: Compile Inno Setup + - name: Compile Inno Setup (x64) run: | - & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" installer.iss + & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" /DArch=x64 /DBuildDir=..\..\target\release packaging/windows/installer.iss - - name: Calculate Checksum + - name: Add ARM64 target + run: rustup target add aarch64-pc-windows-msvc + + - name: Build Rust Project (ARM64) + run: cargo build --release --target aarch64-pc-windows-msvc + env: + POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} + + - name: Compile Inno Setup (ARM64) + run: | + & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" /DArch=arm64 /DBuildDir=..\..\target\aarch64-pc-windows-msvc\release packaging/windows/installer.iss + + - name: Calculate Checksums shell: pwsh run: | - $file = "user_mode_dist/Next_Tablet_Driver_Setup_x64.exe" - $hash = (Get-FileHash -Path $file -Algorithm SHA256).Hash.ToLower() - "$hash Next_Tablet_Driver_Setup_x64.exe" | Out-File -Encoding utf8 "$file.sha256" + $files = @("user_mode_dist/Next_Tablet_Driver_Setup_x64.exe", "user_mode_dist/Next_Tablet_Driver_Setup_arm64.exe") + foreach ($file in $files) { + if (Test-Path $file) { + $hash = (Get-FileHash -Path $file -Algorithm SHA256).Hash.ToLower() + $name = Split-Path $file -Leaf + "$hash $name" | Out-File -Encoding utf8 "$file.sha256" + } + } - name: Upload Windows artifacts uses: actions/upload-artifact@v4 @@ -54,6 +71,8 @@ jobs: path: | user_mode_dist/Next_Tablet_Driver_Setup_x64.exe user_mode_dist/Next_Tablet_Driver_Setup_x64.exe.sha256 + user_mode_dist/Next_Tablet_Driver_Setup_arm64.exe + user_mode_dist/Next_Tablet_Driver_Setup_arm64.exe.sha256 build-linux: name: Build Linux AppImage and .deb @@ -66,7 +85,7 @@ jobs: - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable with: - toolchain: "1.95" + toolchain: "1.96.1" components: clippy,rustfmt - name: Ensure components @@ -162,6 +181,113 @@ jobs: *.zsync next_tablet_driver_*.deb + build-linux-arm64: + name: Build Linux ARM64 AppImage and .deb + runs-on: ubuntu-24.04-arm + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.96.1" + components: clippy,rustfmt + + - name: Ensure components + run: | + rustup component add rustfmt clippy || true + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libudev-dev patchelf libfuse2 wget unzip dpkg-dev build-essential \ + libgtk-3-dev libglib2.0-dev libgdk-pixbuf2.0-dev libpango1.0-dev libcairo2-dev libatk1.0-dev \ + libx11-dev libxrandr-dev libxfixes-dev libxrender-dev libx11-xcb-dev libxkbcommon-dev \ + libgl1-mesa-dev libegl1-mesa-dev libgles2-mesa-dev libxdo-dev + + - name: Build release (glibc arm64) + run: cargo build --workspace --release --all-features + env: + POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} + + - name: Prepare AppDir + run: | + mkdir -p AppDir/usr/bin + mkdir -p AppDir/usr/share/applications + mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps + cp target/release/next_tablet_driver AppDir/usr/bin/next_tablet_driver + chmod +x AppDir/usr/bin/next_tablet_driver + if [ -f resources/icon.png ]; then + cp resources/icon.png AppDir/usr/share/icons/hicolor/256x256/apps/next_tablet_driver.png + fi + printf '%s\n' '[Desktop Entry]' 'Name=NextTabletDriver' 'Exec=next_tablet_driver' 'Icon=next_tablet_driver' 'Type=Application' 'Categories=Utility;Graphics;' > AppDir/usr/share/applications/next_tablet_driver.desktop + + - name: Download linuxdeploy and plugin + run: | + curl -sL -o linuxdeploy-aarch64.AppImage "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-aarch64.AppImage" + curl -sL -o linuxdeploy-plugin-appimage-aarch64.AppImage "https://github.com/linuxdeploy/linuxdeploy-plugin-appimage/releases/download/continuous/linuxdeploy-plugin-appimage-aarch64.AppImage" + chmod a+x linuxdeploy-*.AppImage + + - name: Create AppImage + env: + APPIMAGE_EXTRACT_AND_RUN: "1" + # Embed update information pointing to GitHub Releases (zsync transport) + LDAI_UPDATE_INFORMATION: "gh-releases-zsync|${{ github.repository_owner }}|${{ github.event.repository.name }}|latest|next_tablet_driver-*aarch64.AppImage.zsync" + # Force output filename to include tag/version + LDAI_OUTPUT: "next_tablet_driver-${{ github.event.inputs.ref_name || github.ref_name }}-aarch64.AppImage" + LDAI_VERSION: "${{ github.event.inputs.ref_name || github.ref_name }}" + run: | + ./linuxdeploy-aarch64.AppImage --appdir AppDir -d AppDir/usr/share/applications/next_tablet_driver.desktop -i AppDir/usr/share/icons/hicolor/256x256/apps/next_tablet_driver.png --output appimage || true + APPIMAGE=$(ls -1 *.AppImage 2>/dev/null | grep -v linuxdeploy | head -n 1 || true) + if [ -z "$APPIMAGE" ]; then + echo "AppImage creation failed or no AppImage generated" && exit 1 + fi + # The plugin should also generate a .zsync file when LDAI_UPDATE_INFORMATION is set + ZSYNC=$(ls -1 *.zsync 2>/dev/null | head -n 1 || true) + if [ -n "$ZSYNC" ]; then + echo "Created zsync: $ZSYNC" + else + echo "Warning: zsync not generated" + fi + echo "Created AppImage: $APPIMAGE" + + - name: Create .deb package (wraps AppImage) + run: | + set -e + APPIMAGE=$(ls -1 *.AppImage | grep -v linuxdeploy | head -n 1) + if [ -z "$APPIMAGE" ]; then + echo "AppImage not found, cannot build deb" && exit 1 + fi + mkdir -p deb_pkg/DEBIAN + mkdir -p deb_pkg/opt/next_tablet_driver + mkdir -p deb_pkg/usr/bin + + cp "$APPIMAGE" deb_pkg/opt/next_tablet_driver/next_tablet_driver.AppImage + chmod +x deb_pkg/opt/next_tablet_driver/next_tablet_driver.AppImage + + printf '%s\n' '#!/bin/sh' 'exec /opt/next_tablet_driver/next_tablet_driver.AppImage "$@"' > deb_pkg/usr/bin/next_tablet_driver + chmod +x deb_pkg/usr/bin/next_tablet_driver + + VERSION=$(sed -n 's/^version *= *"\(.*\)"/\1/p' Cargo.toml | head -n1 | tr -d '\r') + if [ -z "$VERSION" ]; then + VERSION=${GITHUB_SHA} + fi + + printf '%s\n' "Package: next-tablet-driver" "Version: $VERSION" "Section: utils" "Priority: optional" "Architecture: arm64" "Maintainer: iSweat " "Description: NextTabletDriver portable Linux AppImage" > deb_pkg/DEBIAN/control + + dpkg-deb --build deb_pkg next_tablet_driver_${VERSION}_arm64.deb + + - name: Upload Linux artifacts + uses: actions/upload-artifact@v4 + with: + name: linux-arm64-release + path: | + *.AppImage + *.zsync + next_tablet_driver_*.deb + build-musl: name: Build MUSL (best-effort) runs-on: ubuntu-latest @@ -173,7 +299,7 @@ jobs: - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable with: - toolchain: "1.95" + toolchain: "1.96.1" components: clippy,rustfmt - name: Ensure components @@ -218,7 +344,7 @@ jobs: publish-release: name: Publish Release - needs: [build-windows, build-linux, build-musl] + needs: [build-windows, build-linux, build-linux-arm64, build-musl] runs-on: ubuntu-latest steps: @@ -237,6 +363,12 @@ jobs: name: linux-release path: downloaded_artifacts/linux + - name: Download Linux ARM64 artifacts + uses: actions/download-artifact@v4 + with: + name: linux-arm64-release + path: downloaded_artifacts/linux-arm64 + - name: Download MUSL artifacts (if any) uses: actions/download-artifact@v4 with: diff --git a/.github/workflows/winget-release.yml b/.github/workflows/winget-release.yml new file mode 100644 index 0000000..3f1b1c5 --- /dev/null +++ b/.github/workflows/winget-release.yml @@ -0,0 +1,17 @@ +name: Publish to WinGet + +on: + release: + types: [released] + +jobs: + winget-release: + name: Publish to WinGet + runs-on: windows-latest + + steps: + - uses: vedantmgoyal9/winget-releaser@v2 + with: + identifier: iSweat.NextTabletDriver + installers-regex: 'Next_Tablet_Driver_Setup_x64\.exe$' + token: ${{ secrets.WINGET_TOKEN }} diff --git a/Cargo.toml b/Cargo.toml index 82f2f44..2e25e1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ authors = ["iSweat "] description = "Tablet Driver for Osu! and Drawing" version = "1.1.0" edition = "2024" -rust-version = "1.95.0" +rust-version = "1.96.1" [dependencies] # Windows & Linux diff --git a/README.md b/README.md index 928480a..c1da70f 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,12 @@ Releases + + CI/CD + Platforms License - Rust + Rust

@@ -154,7 +157,8 @@ cargo test For packaging changes on Arch Linux, validate with: ```bash -makepkg --printsrcinfo +cd packaging/linux +makepkg --printsrcinfo > .SRCINFO makepkg -si ``` diff --git a/.SRCINFO b/packaging/linux/.SRCINFO similarity index 100% rename from .SRCINFO rename to packaging/linux/.SRCINFO diff --git a/PKGBUILD b/packaging/linux/PKGBUILD similarity index 100% rename from PKGBUILD rename to packaging/linux/PKGBUILD diff --git a/installer.iss b/packaging/windows/installer.iss similarity index 66% rename from installer.iss rename to packaging/windows/installer.iss index 8bb1be8..040afa2 100644 --- a/installer.iss +++ b/packaging/windows/installer.iss @@ -1,18 +1,35 @@ +#ifndef Arch +#define Arch "x64" +#endif + +#ifndef BuildDir +#define BuildDir "..\..\target\release" +#endif + +#ifndef OutputFile +#define OutputFile "Next_Tablet_Driver_Setup_" + Arch +#endif + [Setup] AppName=Next Tablet Driver -AppVersion=1.26.1006.01 +AppVersion=1.26.2006.01 AppPublisher=iSweat -OutputBaseFilename=Next_Tablet_Driver_Setup_x64 +OutputBaseFilename={#OutputFile} +#if Arch == "arm64" +ArchitecturesAllowed=arm64 +ArchitecturesInstallIn64BitMode=arm64 +#else ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible +#endif DefaultDirName={commonpf}\NextTabletDriver DefaultGroupName=Next Tablet Driver UninstallDisplayIcon={app}\next_tablet_driver.exe Compression=lzma2 SolidCompression=yes -OutputDir=user_mode_dist +OutputDir=..\..\user_mode_dist PrivilegesRequired=admin PrivilegesRequiredOverridesAllowed=dialog @@ -26,7 +43,7 @@ SetupMutex=NextTabletDriverSetupMutex Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked [Files] -Source: "target\release\next_tablet_driver.exe"; DestDir: "{app}"; Flags: ignoreversion restartreplace +Source: "{#BuildDir}\next_tablet_driver.exe"; DestDir: "{app}"; Flags: ignoreversion restartreplace [Icons] Name: "{group}\Next Tablet Driver"; Filename: "{app}\next_tablet_driver.exe" diff --git a/rust-toolchain.toml b/rust-toolchain.toml index b8d0a35..c5cf161 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.95" +channel = "1.96.1" targets = [ "x86_64-unknown-linux-gnu", "x86_64-pc-windows-gnu" ] diff --git a/src/app/autoupdate/installer.rs b/src/app/autoupdate/installer.rs index b85557f..346e39c 100644 --- a/src/app/autoupdate/installer.rs +++ b/src/app/autoupdate/installer.rs @@ -47,7 +47,7 @@ pub fn download_and_install( return Err(format!("Download failed: {}", response.status()).into()); } - let total_size = response + let _total_size = response .header("Content-Length") .and_then(|v| v.parse::().ok()) .unwrap_or(0); @@ -60,6 +60,9 @@ pub fn download_and_install( let mut buffer = [0u8; 8192]; let mut reader = response.into_reader(); + let mut last_update_time = std::time::Instant::now(); + let mut last_downloaded: u64 = 0; + { let mut file = fs::File::create(&temp_path)?; loop { @@ -72,11 +75,26 @@ pub fn download_and_install( } downloaded += bytes_read as u64; - if total_size > 0 { - let progress = downloaded as f32 / total_size as f32; - let _ = status_sender.send(UpdateStatus::Downloading(progress)); + let now = std::time::Instant::now(); + let elapsed_ms = now.duration_since(last_update_time).as_millis(); + if elapsed_ms >= 250 { + let elapsed_secs = elapsed_ms as f64 / 1000.0; + let speed = ((downloaded - last_downloaded) as f64 / elapsed_secs) as u64; + last_update_time = now; + last_downloaded = downloaded; + + let _ = status_sender.send(UpdateStatus::Downloading( + crate::app::autoupdate::models::DownloadProgress { downloaded, speed }, + )); } } + + let _ = status_sender.send(UpdateStatus::Downloading( + crate::app::autoupdate::models::DownloadProgress { + downloaded, + speed: 0, + }, + )); } // Verify SHA256 mandatory - read the file back from disk to prevent TOCTOU attacks @@ -148,7 +166,16 @@ pub fn download_and_install( let status = Command::new(&launch_path).spawn(); #[cfg(not(target_os = "linux"))] - let status = Command::new(&temp_path).spawn(); + let status = Command::new(&temp_path) + .args([ + "/SP-", + "/SILENT", + "/SUPPRESSMSGBOXES", + "/NORESTART", + "/CLOSEAPPLICATIONS", + "/TASKS=desktopicon", + ]) + .spawn(); match status { Ok(_) => { @@ -163,7 +190,14 @@ pub fn download_and_install( let _ = fs::remove_file(&temp_path); log::error!(target: "Update::Process", "Failed to launch installer: {e}"); - Err(e.into()) + crate::app::telemetry::capture_event( + "update_failed", + Some(serde_json::json!({ + "error_message": e.to_string(), + "context": "Launch Installer" + })), + ); + Err("Installer launch failed".into()) } } } @@ -171,30 +205,24 @@ pub fn download_and_install( /// Finds the appropriate release asset for the current platform. fn find_platform_asset(release: &Release) -> Result<&Asset, Box> { #[cfg(windows)] - { - release - .assets - .iter() - .find(|a| { - std::path::Path::new(&a.name) - .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("exe")) - }) - .ok_or_else(|| "No suitable installer (.exe) asset found in release".into()) - } + return release + .assets + .iter() + .find(|a| { + std::path::Path::new(&a.name) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("exe")) + }) + .ok_or_else(|| "No suitable installer (.exe) asset found in release".into()); #[cfg(target_os = "linux")] - { - release - .assets - .iter() - .find(|a| a.name.ends_with(".AppImage")) - .or_else(|| release.assets.iter().find(|a| a.name.ends_with(".tar.gz"))) - .ok_or_else(|| "No suitable Linux asset found in release".into()) - } + return release + .assets + .iter() + .find(|a| a.name.ends_with(".AppImage")) + .or_else(|| release.assets.iter().find(|a| a.name.ends_with(".tar.gz"))) + .ok_or_else(|| "No suitable Linux asset found in release".into()); #[cfg(not(any(windows, target_os = "linux")))] - { - Err("Unsupported platform for auto-update".into()) - } + return Err("Unsupported platform for auto-update".into()); } diff --git a/src/app/autoupdate/models.rs b/src/app/autoupdate/models.rs index b7a5584..74a0ab9 100644 --- a/src/app/autoupdate/models.rs +++ b/src/app/autoupdate/models.rs @@ -16,6 +16,12 @@ pub struct Asset { pub browser_download_url: String, } +#[derive(Clone, Default)] +pub struct DownloadProgress { + pub downloaded: u64, + pub speed: u64, +} + /// Represents the current phase/status of the update process. /// This enum is passed via channels from the update thread to the main UI thread. #[derive(Clone)] @@ -23,7 +29,7 @@ pub enum UpdateStatus { Idle, Checking, Available(Release), - Downloading(f32), + Downloading(DownloadProgress), ReadyToInstall(PathBuf), Error(String), } diff --git a/src/app/events.rs b/src/app/events.rs index 7674b6c..7bbe0d4 100644 --- a/src/app/events.rs +++ b/src/app/events.rs @@ -47,11 +47,16 @@ impl TabletMapperApp { } // Check for updates - if let Ok(status) = self.update_receiver.try_recv() { + let mut got_update = false; + while let Ok(status) = self.update_receiver.try_recv() { if let crate::app::autoupdate::UpdateStatus::Available(release) = &status { log::info!(target: "Update", "Update available: {}", release.tag_name); } self.update_status = status; + got_update = true; + } + if got_update { + ctx.request_repaint(); } } diff --git a/src/app/services/update.rs b/src/app/services/update.rs index 8457630..d34e911 100644 --- a/src/app/services/update.rs +++ b/src/app/services/update.rs @@ -32,6 +32,13 @@ impl UpdateService { Ok(None) => {} Err(e) => { log::error!(target: "Update", "Failed to check for updates: {e}"); + crate::app::telemetry::capture_event( + "update_failed", + Some(serde_json::json!({ + "error_message": e.to_string(), + "context": "Update Check" + })), + ); } }); } diff --git a/src/app/state/mod.rs b/src/app/state/mod.rs index a54c58a..95ce667 100644 --- a/src/app/state/mod.rs +++ b/src/app/state/mod.rs @@ -220,6 +220,10 @@ impl TabletMapperApp { profile_name: self.profile.name.clone(), profile_path: self.profile.path.clone(), }); + crate::app::telemetry::capture_event( + "profile_saved_as", + Some(serde_json::json!({ "profile_name": self.profile.name })), + ); self.push_toast(t!("toast.settings_saved"), ToastLevel::Info); } Err(e) => { @@ -295,7 +299,7 @@ impl TabletMapperApp { "OTD settings imported successfully".to_string(), ToastLevel::Info, ); - crate::app::telemetry::capture_event("otd_imported", None, &self.app_prefs); + crate::app::telemetry::capture_event("otd_imported", None); } Err(e) => self.push_toast( format!("Failed to import OTD settings: {e}"), @@ -364,7 +368,9 @@ impl TabletMapperApp { let _ = sender.send(UpdateStatus::Error(e.to_string())); } }); - self.update_status = UpdateStatus::Downloading(0.0); + self.update_status = UpdateStatus::Downloading( + crate::app::autoupdate::models::DownloadProgress::default(), + ); } } diff --git a/src/app/state/models.rs b/src/app/state/models.rs index 172606d..255d118 100644 --- a/src/app/state/models.rs +++ b/src/app/state/models.rs @@ -124,7 +124,7 @@ impl Metrics { if elapsed >= std::time::Duration::from_millis(200) { let delta = current_packets.saturating_sub(self.last_packet_count); let hz = delta as f32 / elapsed.as_secs_f32(); - self.displayed_hz += (hz - self.displayed_hz) * 0.3; + self.displayed_hz = hz.mul_add(0.3, self.displayed_hz); self.last_packet_count = current_packets; self.last_hz_update = Instant::now(); } @@ -135,7 +135,8 @@ impl Metrics { self.ui_latency_ms = latency; self.min_ui_latency_ms = self.min_ui_latency_ms.min(latency); self.max_ui_latency_ms = self.max_ui_latency_ms.max(latency); - self.avg_ui_latency_ms += (latency - self.avg_ui_latency_ms) * 0.1; + self.avg_ui_latency_ms = + (latency - self.avg_ui_latency_ms).mul_add(0.1, self.avg_ui_latency_ms); } /// Resets all accumulated UI frame latency tracking statistics. diff --git a/src/app/telemetry.rs b/src/app/telemetry.rs index 52d21d8..8b8d7a6 100644 --- a/src/app/telemetry.rs +++ b/src/app/telemetry.rs @@ -1,77 +1,262 @@ -use serde_json::json; +use crossbeam_channel::{Receiver, Sender, bounded}; +use serde_json::{Value, json}; +use std::collections::HashSet; +use std::sync::OnceLock; use std::thread; use std::time::Duration; -// Use an environment variable at compile time to avoid hardcoding the key in open-source code. const POSTHOG_API_KEY: Option<&str> = option_env!("POSTHOG_API_KEY"); -const POSTHOG_URL: &str = "https://eu.i.posthog.com/capture/"; +const POSTHOG_BATCH_URL: &str = "https://eu.i.posthog.com/batch/"; -/// Sends an anonymous telemetry event to `PostHog` in the background. -/// -/// If telemetry is disabled, this function does nothing. -pub fn capture_event( +static TELEMETRY_SENDER: OnceLock> = OnceLock::new(); + +#[derive(Debug)] +enum TelemetryMessage { + Event { + event_name: String, + properties: Option, + set_properties: Option, + dedup_key: Option, + }, +} + +pub struct TelemetryService; + +impl TelemetryService { + /// Initializes the global telemetry worker. Should be called once at startup. + pub fn init(telemetry_id: String, enabled: bool) { + if !enabled { + return; + } + + let Some(api_key) = POSTHOG_API_KEY else { + return; // No API key, no telemetry + }; + + // Create a bounded channel with a large enough capacity to handle bursts, + // but not so large that it consumes too much memory. + let (sender, receiver) = bounded(1000); + + if TELEMETRY_SENDER.set(sender).is_ok() { + let worker = TelemetryWorker { + receiver, + api_key: api_key.to_string(), + distinct_id: telemetry_id, + batch_queue: Vec::new(), + sent_dedup_keys: HashSet::new(), + }; + + if let Err(e) = thread::Builder::new() + .name("TelemetryWorker".into()) + .spawn(move || worker.run()) + { + log::error!(target: "Telemetry", "Failed to spawn TelemetryWorker: {e}"); + } + } + } +} + +/// Helper function to send an event to the background worker. +pub fn capture_event(event_name: &str, properties: Option) { + send_message(TelemetryMessage::Event { + event_name: event_name.to_string(), + properties, + set_properties: None, + dedup_key: None, + }); +} + +/// Capture an event and update user profile ($set) simultaneously. +pub fn capture_event_with_set( event_name: &str, - properties: Option, - app_prefs: &crate::settings::app_preferences::AppPreferences, + properties: Option, + set_properties: Option, ) { - let Some(api_key) = POSTHOG_API_KEY else { - return; // No telemetry without an API key - }; + send_message(TelemetryMessage::Event { + event_name: event_name.to_string(), + properties, + set_properties, + dedup_key: None, + }); +} + +/// Capture an event but deduplicate it for the current session using the given key. +/// Helpful to avoid spamming `tablet_connected` 20 times if there is a USB bug. +pub fn capture_event_dedup( + event_name: &str, + properties: Option, + set_properties: Option, + dedup_key: &str, +) { + send_message(TelemetryMessage::Event { + event_name: event_name.to_string(), + properties, + set_properties, + dedup_key: Some(dedup_key.to_string()), + }); +} - if !app_prefs.telemetry_enabled { - return; +fn send_message(msg: TelemetryMessage) { + if let Some(sender) = TELEMETRY_SENDER.get() { + // try_send is completely non-blocking. If the queue is full (1000 events backlogged), + // we just drop the event to prioritize driver performance over telemetry. + if let Err(e) = sender.try_send(msg) { + log::trace!(target: "Telemetry", "Dropped telemetry event: {e}"); + } } +} + +struct TelemetryWorker { + receiver: Receiver, + api_key: String, + distinct_id: String, + batch_queue: Vec, + sent_dedup_keys: HashSet, +} + +impl TelemetryWorker { + fn run(mut self) { + let batch_size_limit = 50; + let batch_timeout = Duration::from_secs(5); - let distinct_id = app_prefs.telemetry_id.clone(); - let event = event_name.to_string(); - let mut props = properties.unwrap_or_else(|| json!({})); + // Use a single ureq agent to reuse connections + let agent = ureq::builder().timeout(Duration::from_secs(10)).build(); - // Inject global properties - if let Some(obj) = props.as_object_mut() { - obj.insert("os".to_string(), json!(std::env::consts::OS)); - obj.insert("arch".to_string(), json!(std::env::consts::ARCH)); - obj.insert("version".to_string(), json!(crate::VERSION)); + loop { + match self.receiver.recv_timeout(batch_timeout) { + Ok(TelemetryMessage::Event { + event_name, + properties, + set_properties, + dedup_key, + }) => { + if let Some(ref key) = dedup_key { + let full_key = format!("{event_name}_{key}"); + if self.sent_dedup_keys.contains(&full_key) { + continue; // Deduplicated, ignore this event + } + self.sent_dedup_keys.insert(full_key); + } + + let payload = self.build_event_payload( + &event_name, + properties.as_ref(), + set_properties.as_ref(), + ); + self.batch_queue.push(payload); + + if self.batch_queue.len() >= batch_size_limit { + self.flush_batch(&agent); + } + } + Err(crossbeam_channel::RecvTimeoutError::Timeout) => { + if !self.batch_queue.is_empty() { + self.flush_batch(&agent); + } + } + Err(crossbeam_channel::RecvTimeoutError::Disconnected) => { + self.flush_batch(&agent); + break; + } + } + } } - let payload = json!({ - "api_key": api_key, - "event": event, - "properties": { - "distinct_id": distinct_id, + fn build_event_payload( + &self, + event_name: &str, + custom_props: Option<&Value>, + set_props: Option<&Value>, + ) -> Value { + let mut props = json!({ + "distinct_id": self.distinct_id, + "os": std::env::consts::OS, + "arch": std::env::consts::ARCH, + "version": crate::VERSION, + "$lib": "ureq_batch", + "$lib_version": "2.12.1", "$set_once": { "initial_os": std::env::consts::OS, "initial_arch": std::env::consts::ARCH, }, - "$lib": "ureq", - "$lib_version": "2.12.1", - }, - }); + }); - // Merge custom properties into the payload's properties object - let mut final_payload = payload; - if let Some(payload_props) = final_payload - .get_mut("properties") - .and_then(|p| p.as_object_mut()) - && let Some(custom_props) = props.as_object() - { - for (k, v) in custom_props { - payload_props.insert(k.clone(), v.clone()); + if let Some(payload_props) = props.as_object_mut() { + if let Some(custom_obj) = custom_props.and_then(|c| c.as_object()) { + for (k, v) in custom_obj { + payload_props.insert(k.clone(), v.clone()); + } + } + if let Some(set_p) = set_props { + payload_props.insert("$set".to_string(), set_p.clone()); + } } + + json!({ + "event": event_name, + "properties": props, + "timestamp": chrono::Utc::now().to_rfc3339(), + }) } - // Fire and forget on a separate thread to not block the UI - thread::spawn(move || { - let agent = ureq::builder().timeout(Duration::from_secs(5)).build(); + fn flush_batch(&mut self, agent: &ureq::Agent) { + if self.batch_queue.is_empty() { + return; + } + + let batch_payload = json!({ + "api_key": self.api_key, + "batch": self.batch_queue, + }); let res = agent - .post(POSTHOG_URL) + .post(POSTHOG_BATCH_URL) .set("Content-Type", "application/json") - .send_json(final_payload); + .send_json(&batch_payload); - if let Err(e) = res { - log::trace!(target: "Telemetry", "Failed to send telemetry event: {e}"); + match res { + Ok(_) => { + log::trace!(target: "Telemetry", "Successfully sent batch of {} events", self.batch_queue.len()); + self.batch_queue.clear(); + } + Err(e) => { + log::trace!(target: "Telemetry", "Failed to send telemetry batch: {e}"); + // Simple offline resilience: if the batch fails, we keep the queue. + // To avoid memory leak if offline forever, cap the queue at 500. + if self.batch_queue.len() > 500 { + // Drain the oldest items to make room + self.batch_queue.drain(0..(self.batch_queue.len() - 500)); + } + } } - }); + } +} + +pub fn setup_panic_hook() { + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + let payload = panic_info.to_string(); + let anonymized = anonymize_path(&payload); + + let crash_file = crate::settings::get_settings_dir().join("crash_report.json"); + if let Ok(json) = serde_json::to_string(&json!({ "panic_message": anonymized })) { + let _ = std::fs::write(crash_file, json); + } + + default_hook(panic_info); + })); +} + +pub fn send_pending_crash_reports() { + let crash_file = crate::settings::get_settings_dir().join("crash_report.json"); + if crash_file.exists() { + if let Ok(content) = std::fs::read_to_string(&crash_file) + && let Ok(json) = serde_json::from_str::(&content) + { + capture_event("app_panicked", Some(json)); + } + let _ = std::fs::remove_file(crash_file); + } } fn anonymize_path(message: &str) -> String { @@ -119,33 +304,6 @@ fn anonymize_path_impl( cleaned } -pub fn setup_panic_hook() { - let default_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |panic_info| { - let payload = panic_info.to_string(); - let anonymized = anonymize_path(&payload); - - let crash_file = crate::settings::get_settings_dir().join("crash_report.json"); - if let Ok(json) = serde_json::to_string(&json!({ "panic_message": anonymized })) { - let _ = std::fs::write(crash_file, json); - } - - default_hook(panic_info); - })); -} - -pub fn send_pending_crash_reports(app_prefs: &crate::settings::app_preferences::AppPreferences) { - let crash_file = crate::settings::get_settings_dir().join("crash_report.json"); - if crash_file.exists() { - if let Ok(content) = std::fs::read_to_string(&crash_file) - && let Ok(json) = serde_json::from_str::(&content) - { - capture_event("app_panicked", Some(json), app_prefs); - } - let _ = std::fs::remove_file(crash_file); - } -} - #[cfg(test)] #[allow( clippy::unwrap_used, diff --git a/src/app/update.rs b/src/app/update.rs index 74ca072..37f9cdd 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -92,7 +92,6 @@ impl TabletMapperApp { crate::app::telemetry::capture_event( "theme_downloaded", Some(serde_json::json!({ "theme_name": safe_name })), - &self.app_prefs, ); } Err(e) => { diff --git a/src/app/websocket.rs b/src/app/websocket.rs index b5f1b1d..4530a4b 100644 --- a/src/app/websocket.rs +++ b/src/app/websocket.rs @@ -133,12 +133,9 @@ pub fn websocket_loop(shared: &Arc) { next_client_id += 1; // Track connection - let prefs = - crate::settings::app_preferences::load_app_preferences(); crate::app::telemetry::capture_event( "websocket_client_connected", None, - &prefs, ); } } diff --git a/src/engine/tablet_manager.rs b/src/engine/tablet_manager.rs index 15201df..04f0eb2 100644 --- a/src/engine/tablet_manager.rs +++ b/src/engine/tablet_manager.rs @@ -69,14 +69,12 @@ fn manager_thread_iteration(shared_clone: &Arc, sender_clone: &Send Err(e) => { log::error!(target: "HID", "CRITICAL: Failed to initialise HID API: {e}"); let error_str = e.to_string(); - let app_prefs = crate::settings::app_preferences::load_app_preferences(); crate::app::telemetry::capture_event( "engine_error", Some(serde_json::json!({ "error_message": error_str, "context": "HID API Initialization" })), - &app_prefs, ); *shared_clone .engine_status @@ -230,15 +228,18 @@ fn on_device_connected( *shared.device_state.write().unwrap_or_reset("device_state") = new_device.clone(); log::info!(target: "TabletManager", "Tablet metadata populated: {}", new_device.name); - let app_prefs = crate::settings::app_preferences::load_app_preferences(); - crate::app::telemetry::capture_event( + crate::app::telemetry::capture_event_dedup( "tablet_connected", Some(serde_json::json!({ "tablet_model": new_device.name, "vendor_id": format!("{:#06X}", new_device.vid), "product_id": format!("{:#06X}", new_device.pid), })), - &app_prefs, + Some(serde_json::json!({ + "tablets_owned": [&new_device.name], + "last_tablet_connected": &new_device.name, + })), + &new_device.name, ); let mut is_first = shared.is_first_run.write().unwrap_or_reset("is_first_run"); @@ -439,13 +440,14 @@ fn process_packet( stats.hid_read_ms = hr_ms; stats.min_hid_read_ms = stats.min_hid_read_ms.min(hr_ms); stats.max_hid_read_ms = stats.max_hid_read_ms.max(hr_ms); - stats.avg_hid_read_ms += (hr_ms - stats.avg_hid_read_ms) * 0.05; + stats.avg_hid_read_ms = + (hr_ms - stats.avg_hid_read_ms).mul_add(0.05, stats.avg_hid_read_ms); let p_ms = parse_duration.as_secs_f32() * 1000.0; stats.parser_ms = p_ms; stats.min_parser_ms = stats.min_parser_ms.min(p_ms); stats.max_parser_ms = stats.max_parser_ms.max(p_ms); - stats.avg_parser_ms += (p_ms - stats.avg_parser_ms) * 0.05; + stats.avg_parser_ms = (p_ms - stats.avg_parser_ms).mul_add(0.05, stats.avg_parser_ms); } // Only send to the UI channel when the window is visible. diff --git a/src/filters/antichatter.rs b/src/filters/antichatter.rs index ef287d1..53f043b 100644 --- a/src/filters/antichatter.rs +++ b/src/filters/antichatter.rs @@ -78,8 +78,8 @@ impl Filter for DevocubAntichatter { let vx = x - px; let vy = y - py; - out_x += vx * conf.prediction_strength * conf.prediction_sharpness; - out_y += vy * conf.prediction_strength * conf.prediction_sharpness; + out_x = (vx * conf.prediction_strength).mul_add(conf.prediction_sharpness, out_x); + out_y = (vy * conf.prediction_strength).mul_add(conf.prediction_sharpness, out_y); } self.last_x = out_x; diff --git a/src/lib.rs b/src/lib.rs index 981c8fa..423fb2f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -50,4 +50,4 @@ pub mod startup; pub mod ui; /// Version. -pub const VERSION: &str = "1.26.2006.01"; +pub const VERSION: &str = "1.26.2806.01"; diff --git a/src/main.rs b/src/main.rs index 61e5b45..299950a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -206,26 +206,15 @@ fn main() -> eframe::Result { let shared = SharedStateFactory::create(config.clone(), is_first_run); let app_prefs = next_tablet_driver::settings::app_preferences::load_app_preferences(); - next_tablet_driver::app::telemetry::send_pending_crash_reports(&app_prefs); + next_tablet_driver::app::telemetry::TelemetryService::init( + app_prefs.telemetry_id.clone(), + app_prefs.telemetry_enabled, + ); + + next_tablet_driver::app::telemetry::send_pending_crash_reports(); next_tablet_driver::i18n::set_locale(app_prefs.language); let total_ram_gb = next_tablet_driver::startup::get_memory_info() .map(|b| (b as f64 / 1_073_741_824.0).ceil() as u64); - let cpu_cores = std::env::var("NUMBER_OF_PROCESSORS") - .ok() - .and_then(|s| s.parse::().ok()); - next_tablet_driver::app::telemetry::capture_event( - "app_started", - Some(serde_json::json!({ - "language": app_prefs.language.display_name().to_string(), - "cpu_cores": cpu_cores, - "total_ram_gb": total_ram_gb, - "driver_mode": format!("{:?}", config.mode), - "filter_antichatter_enabled": config.antichatter.enabled, - "filter_prediction_enabled": config.antichatter.prediction_enabled, - "filter_antichatter_strength": config.antichatter.antichatter_strength, - })), - &app_prefs, - ); let state_duration = state_start.elapsed(); // 3. Initialize Services and Channels @@ -262,6 +251,26 @@ fn main() -> eframe::Result { startup_start.elapsed() ); + let cpu_cores = std::env::var("NUMBER_OF_PROCESSORS") + .ok() + .and_then(|s| s.parse::().ok()); + + let theme_name = format!("{:?}", app_prefs.theme); + + next_tablet_driver::app::telemetry::capture_event_with_set( + "app_started", + Some(serde_json::json!({ + "startup_time_ms": startup_start.elapsed().as_millis(), + })), + Some(serde_json::json!({ + "language": app_prefs.language.display_name().to_string(), + "cpu_cores": cpu_cores, + "total_ram_gb": total_ram_gb, + "driver_mode": format!("{:?}", config.mode), + "current_theme": theme_name, + })), + ); + // 6. Main App Loop // eframe blocks until the window is closed. When minimized to tray, the window closes // and eframe returns. We sleep until the tray restores the window, then restart eframe. @@ -319,6 +328,21 @@ fn main() -> eframe::Result { log::info!(target: "Tracking", "GPU Renderer: {renderer}"); log::info!(target: "Tracking", "GPU Vendor: {vendor}"); log::info!(target: "Tracking", "OpenGL Version: {version}"); + + let displays = display_info::DisplayInfo::all().unwrap_or_default(); + let max_hz = displays.iter().map(|d| d.frequency).fold(0.0, f32::max); + + next_tablet_driver::app::telemetry::capture_event_with_set( + "gpu_initialized", + None, + Some(serde_json::json!({ + "gpu_renderer": renderer, + "gpu_vendor": vendor, + "opengl_version": version, + "monitors_count": displays.len(), + "max_refresh_rate_hz": max_hz + })), + ); } Ok(Box::new(TabletMapperApp::new( diff --git a/src/ui/components/update_dialog.rs b/src/ui/components/update_dialog.rs index 475090c..2b9b9c6 100644 --- a/src/ui/components/update_dialog.rs +++ b/src/ui/components/update_dialog.rs @@ -2,124 +2,235 @@ use crate::app::state::TabletMapperApp; use crate::t; use eframe::egui; -pub fn render_update_dialog(app: &mut TabletMapperApp, ctx: &egui::Context) { - if let crate::app::autoupdate::UpdateStatus::Available(release) = &app.update_status { - let screen_rect = ctx.content_rect(); - - // Semi-transparent backdrop to dim the content behind the dialog. - // Uses a dark overlay regardless of theme - this is intentional for modal focus. - egui::Area::new(egui::Id::new("update_overlay")) - .interactable(true) - .fixed_pos(screen_rect.min) - .show(ctx, |ui| { - ui.painter() - .rect_filled(screen_rect, 0.0, egui::Color32::from_black_alpha(180)); +fn render_markdown( + ui: &mut egui::Ui, + text: &str, + text_color: egui::Color32, + strong_color: egui::Color32, +) { + ui.spacing_mut().item_spacing.y = 4.0; + for line in text.lines() { + let line = line.trim(); + if line.is_empty() { + ui.add_space(6.0); + continue; + } + + if line.starts_with("##") || line.starts_with('#') { + let clean = line.trim_start_matches('#').trim(); + ui.add_space(4.0); + ui.add( + egui::Label::new( + egui::RichText::new(clean) + .strong() + .size(15.0) + .color(strong_color), + ) + .wrap_mode(egui::TextWrapMode::Wrap), + ); + ui.add_space(2.0); + } else if line.starts_with("- ") || line.starts_with("* ") { + let clean = line[2..].trim().replace("**", ""); + ui.horizontal(|ui| { + ui.add_space(10.0); + ui.label(egui::RichText::new("•").strong().color(strong_color)); + ui.add( + egui::Label::new(egui::RichText::new(clean).size(13.0).color(text_color)) + .wrap_mode(egui::TextWrapMode::Wrap), + ); }); + } else { + let clean = line.replace("**", ""); + ui.add( + egui::Label::new(egui::RichText::new(clean).size(13.0).color(text_color)) + .wrap_mode(egui::TextWrapMode::Wrap), + ); + } + } +} - let mut open = true; - let version = release.tag_name.clone(); - let body = release - .body - .clone() - .unwrap_or_else(|| t!("dialog.update.no_changelog")); - - let v = ctx.style().visuals.clone(); - let dialog_bg = v.window_fill; - let header_bg = v.panel_fill; - let border_color = v.widgets.noninteractive.bg_stroke.color; - let strong_text = v.strong_text_color(); - let weak_text = v.weak_text_color(); - let text_color = v.text_color(); - let accent = v.selection.bg_fill; - // Use a contrasting text on the accent button: white for dark accents, strong_text for light ones - let accent_text = strong_text; - - egui::Window::new(t!("dialog.update.title")) - .title_bar(false) - .collapsible(false) - .resizable(false) - .pivot(egui::Align2::CENTER_CENTER) - .default_pos(screen_rect.center()) - .fixed_size([450.0, 350.0]) - .open(&mut open) - .frame( - egui::Frame::window(&ctx.style()) - .fill(dialog_bg) - .corner_radius(4.0) - .inner_margin(0.0) - .stroke(egui::Stroke::new(1.0, border_color)), - ) - .show(ctx, |ui| { - ui.vertical(|ui| { - egui::Frame::new() - .fill(header_bg) - .corner_radius(egui::CornerRadius { - nw: 12, - ne: 12, - sw: 0, - se: 0, - }) - .inner_margin(20.0) - .show(ui, |ui| { - ui.set_width(ui.available_width()); - ui.vertical_centered(|ui| { - ui.label( - egui::RichText::new(t!("dialog.update.header")) - .size(24.0) - .strong() - .color(strong_text), - ); - ui.add_space(5.0); - ui.label( - egui::RichText::new(t!( - "dialog.update.version", - version = &version - )) - .size(14.0) - .color(weak_text), - ); - }); +pub fn render_update_dialog(app: &mut TabletMapperApp, ctx: &egui::Context) { + let (is_downloading, dl_stats, release) = match &app.update_status { + crate::app::autoupdate::UpdateStatus::Available(release) => (false, None, Some(release)), + crate::app::autoupdate::UpdateStatus::Downloading(p) => (true, Some(p.clone()), None), + _ => return, + }; + + let screen_rect = ctx.content_rect(); + + // Semi-transparent backdrop + egui::Area::new(egui::Id::new("update_overlay")) + .interactable(true) + .fixed_pos(screen_rect.min) + .show(ctx, |ui| { + ui.painter() + .rect_filled(screen_rect, 0.0, egui::Color32::from_black_alpha(200)); + }); + + let mut open = true; + let version = release.map_or_else(|| "Updating...".to_string(), |r| r.tag_name.clone()); + let body = release + .and_then(|r| r.body.clone()) + .unwrap_or_else(|| t!("dialog.update.no_changelog")); + + let v = ctx.style().visuals.clone(); + let dialog_bg = v.window_fill; + let header_bg = v.panel_fill; + let border_color = v.widgets.noninteractive.bg_stroke.color; + let strong_text = v.strong_text_color(); + let weak_text = v.weak_text_color(); + let text_color = v.text_color(); + let accent = v.selection.bg_fill; + let accent_text = strong_text; + + egui::Window::new(t!("dialog.update.title")) + .title_bar(false) + .collapsible(false) + .resizable(false) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(screen_rect.center()) + .fixed_size([520.0, 420.0]) + .open(&mut open) + .frame( + egui::Frame::window(&ctx.style()) + .fill(dialog_bg) + .corner_radius(12.0) + .inner_margin(0.0) + .stroke(egui::Stroke::new(1.0, border_color)), + ) + .show(ctx, |ui| { + ui.vertical(|ui| { + // HEADER AREA + egui::Frame::new() + .fill(header_bg) + .corner_radius(egui::CornerRadius { + nw: 12, + ne: 12, + sw: 0, + se: 0, + }) + .inner_margin(egui::Margin::symmetric(24, 24)) + .show(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.vertical_centered(|ui| { + let icon = if is_downloading { + egui_phosphor::regular::CLOUD_ARROW_DOWN + } else { + egui_phosphor::regular::SPARKLE + }; + + ui.label( + egui::RichText::new(format!( + "{} {}", + icon, + t!("dialog.update.header") + )) + .size(28.0) + .strong() + .color(strong_text), + ); + ui.add_space(8.0); + ui.label( + egui::RichText::new(if is_downloading { + "Downloading update...".to_string() + } else { + t!("dialog.update.version", version = &version) + }) + .size(15.0) + .color(weak_text), + ); }); + }); - ui.add_space(15.0); + ui.add_space(20.0); - egui::Frame::new() - .inner_margin(egui::Margin::symmetric(20, 0)) - .show(ui, |ui| { + // BODY AREA + egui::Frame::new() + .inner_margin(egui::Margin::symmetric(30, 0)) + .show(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(egui_phosphor::regular::GIFT) + .size(18.0) + .color(accent), + ); ui.label( egui::RichText::new(t!("dialog.update.whats_new")) .strong() - .size(16.0) + .size(18.0) .color(strong_text), ); - ui.add_space(8.0); + }); + + ui.add_space(12.0); + + egui::ScrollArea::vertical() + .max_height(180.0) + .auto_shrink([false; 2]) + .show(ui, |ui| { + render_markdown(ui, &body, text_color, strong_text); + }); + }); + + ui.add_space(30.0); + + // FOOTER AREA + egui::Frame::new() + .inner_margin(egui::Margin::symmetric(30, 15)) + .show(ui, |ui| { + ui.set_min_width(ui.available_width()); + if is_downloading { + if let Some(stats) = dl_stats { + ui.vertical_centered_justified(|ui| { + ui.horizontal(|ui| { + ui.with_layout( + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + ui.add_space(ui.available_width() / 2.0 - 55.0); + ui.spinner(); + ui.add_space(8.0); + ui.label( + egui::RichText::new("Updating...") + .strong() + .color(accent) + .size(15.0), + ); + }, + ); + }); + + ui.add_space(10.0); + + let mb_dl = stats.downloaded as f64 / 1_048_576.0; + let mb_spd = stats.speed as f64 / 1_048_576.0; - egui::ScrollArea::vertical() - .max_height(150.0) - .auto_shrink([false; 2]) - .show(ui, |ui| { ui.label( - egui::RichText::new(body).size(13.0).color(text_color), + egui::RichText::new(format!( + "{mb_dl:.1} MB ({mb_spd:.1} MB/s)" + )) + .color(weak_text) + .size(14.0), ); }); - }); - - ui.add_space(30.0); - - egui::Frame::new() - .inner_margin(egui::Margin::symmetric(20, 10)) - .show(ui, |ui| { + } + } else { ui.horizontal(|ui| { let later_btn = egui::Button::new( egui::RichText::new(t!("dialog.update.later")) - .size(14.0) + .size(15.0) .color(text_color), ) .fill(egui::Color32::TRANSPARENT) .stroke(egui::Stroke::new(1.0, border_color)) - .min_size(egui::vec2(120.0, 36.0)); + .corner_radius(6.0) + .min_size(egui::vec2(130.0, 40.0)); - if ui.add(later_btn).clicked() { + if ui + .add(later_btn) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { app.dismiss_update(); } @@ -127,25 +238,33 @@ pub fn render_update_dialog(app: &mut TabletMapperApp, ctx: &egui::Context) { egui::Layout::right_to_left(egui::Align::Center), |ui| { let update_btn = egui::Button::new( - egui::RichText::new(t!("dialog.update.install")) - .size(14.0) - .strong() - .color(accent_text), + egui::RichText::new(format!( + "{} {}", + egui_phosphor::regular::DOWNLOAD_SIMPLE, + t!("dialog.update.install") + )) + .size(15.0) + .strong() + .color(accent_text), ) .fill(accent) - .corner_radius(4.0) - .min_size(egui::vec2(160.0, 36.0)); + .corner_radius(6.0) + .min_size(egui::vec2(180.0, 40.0)); - if ui.add(update_btn).clicked() { + if ui + .add(update_btn) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { app.start_update(); } }, ); }); - }); + } + }); - ui.add_space(5.0); - }); + ui.add_space(5.0); }); - } + }); } diff --git a/src/ui/panels/filters/antichatter.rs b/src/ui/panels/filters/antichatter.rs index 011b5d1..9bfa7d7 100644 --- a/src/ui/panels/filters/antichatter.rs +++ b/src/ui/panels/filters/antichatter.rs @@ -20,11 +20,9 @@ pub fn render_antichatter_settings(ui: &mut egui::Ui, config: &mut MappingConfig .changed() && config.antichatter.enabled { - let app_prefs = crate::settings::app_preferences::load_app_preferences(); crate::app::telemetry::capture_event( "filter_enabled", Some(serde_json::json!({"filter_name": "Antichatter"})), - &app_prefs, ); } }); diff --git a/src/ui/panels/filters/stats.rs b/src/ui/panels/filters/stats.rs index 74a951d..8ec2eb8 100644 --- a/src/ui/panels/filters/stats.rs +++ b/src/ui/panels/filters/stats.rs @@ -72,11 +72,9 @@ pub fn render_stats_settings( .changed() && config.speed_stats.enabled { - let app_prefs = crate::settings::app_preferences::load_app_preferences(); crate::app::telemetry::capture_event( "filter_enabled", Some(serde_json::json!({"filter_name": "HandSpeed"})), - &app_prefs, ); } }); diff --git a/src/ui/panels/release.rs b/src/ui/panels/release.rs index 974f929..720d898 100644 --- a/src/ui/panels/release.rs +++ b/src/ui/panels/release.rs @@ -30,7 +30,7 @@ const RELEASES: &[ReleaseEntry] = &[ improvements: &[ "Improve: Decoupled UI from SharedState using the Snapshot pattern for better state management", "Improve: Strict separation of domain logic from the presentation layer", - "Improve: Migration to Rust 1.95 toolchain for improved stability", + "Improve: Migration to Rust 1.96.1 toolchain for improved stability", "Improve: Pipeline performance optimizations and Filters UI refinements", "Improve: General codebase health through formatting and syntax standardisation", ],