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 @@
+
+
+
-
+
@@ -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",
],