diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9d6cf5343..37f18f563 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,6 +34,8 @@ jobs: uses: actions/checkout@v6 with: submodules: recursive + - name: Get tags + run: git fetch --tags origin --recurse-submodules=no --force - name: Setup PNPM uses: pnpm/action-setup@v4 - name: Setup Node @@ -71,6 +73,8 @@ jobs: uses: actions/checkout@v6 with: submodules: recursive + - name: Get tags + run: git fetch --tags origin --recurse-submodules=no --force - name: Set up JDK 17 uses: actions/setup-java@v5 with: @@ -98,6 +102,8 @@ jobs: uses: actions/checkout@v6 with: submodules: recursive + - name: Get tags + run: git fetch --tags origin --recurse-submodules=no --force - name: Setup PNPM uses: pnpm/action-setup@v4 - name: Setup Node @@ -129,6 +135,8 @@ jobs: uses: actions/checkout@v6 with: submodules: recursive + - name: Get tags + run: git fetch --tags origin --recurse-submodules=no --force - name: Setup PNPM uses: pnpm/action-setup@v4 - name: Setup Node @@ -201,6 +209,8 @@ jobs: uses: actions/checkout@v6 with: submodules: recursive + - name: Get tags + run: git fetch --tags origin --recurse-submodules=no --force - name: Set up JDK 17 uses: actions/setup-java@v5 with: @@ -231,6 +241,24 @@ jobs: name: release-android path: SlimeVR-android.apk + - name: Build Google Play release bundle + if: startsWith(github.ref, 'refs/tags/') + run: ./gradlew :server:android:bundleRelease + env: + ANDROID_STORE_FILE: ${{ secrets.ANDROID_GPLAY_STORE_FILE }} + ANDROID_STORE_PASSWD: ${{ secrets.ANDROID_GPLAY_STORE_PASSWD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_GPLAY_KEY_ALIAS }} + ANDROID_KEY_PASSWD: ${{ secrets.ANDROID_GPLAY_KEY_PASSWD }} + + - name: Upload the Google Play artifact + uses: actions/upload-artifact@v6 + if: startsWith(github.ref, 'refs/tags/') + with: + # Artifact name + name: 'SlimeVR-Android-GPDev' # optional, default is artifact + # A file, directory or wildcard pattern that describes what to upload + path: server/android/build/outputs/bundle/release/* + create-release: name: Finalize Release Draft needs: [package-desktop, bundle-android, build-server-jar, build-gui-frontend] diff --git a/gui/electron/main/index.ts b/gui/electron/main/index.ts index 35f29090f..2af187d18 100644 --- a/gui/electron/main/index.ts +++ b/gui/electron/main/index.ts @@ -14,7 +14,7 @@ import { IPC_CHANNELS } from '../shared'; import path, { dirname, join } from 'path'; import open from 'open'; import trayIcon from '../resources/icons/icon.png?asset'; -import appleTrayIcon from '../resources/icons/appleTrayIcon.png?asset'; +import appleTrayIcon from '../resources/icons/Square30x30Logo.png?asset'; import { readFile, stat } from 'fs/promises'; import { getPlatform, handleIpc, isPortAvailable } from './utils'; import { @@ -26,7 +26,7 @@ import { getWindowStateFile, } from './paths'; import { stores } from './store'; -import { logger } from './logger'; +import { closeLogger, logger } from './logger'; import { writeFileSync } from 'node:fs'; import { spawn } from 'node:child_process'; @@ -36,11 +36,16 @@ import { ServerStatusEvent } from 'electron/preload/interface'; import { mkdir } from 'node:fs/promises'; import { MenuItem } from 'electron/main'; +// Fixes colors looking washed on linux +// Might affect hdr +if (process.platform === 'linux') { + app.commandLine.appendSwitch('disable-features', 'WaylandWpColorManagerV1'); + app.commandLine.appendSwitch('force-color-profile', 'srgb'); +} -app.setPath('userData', getGuiDataFolder()) -app.setPath('sessionData', join(getGuiDataFolder(), 'electron')) +app.setPath('userData', getGuiDataFolder()); +app.setPath('sessionData', join(getGuiDataFolder(), 'electron')); -// Register custom protocol to handle asset paths with leading slashes protocol.registerSchemesAsPrivileged([ { scheme: 'app', @@ -268,6 +273,9 @@ function createWindow() { case 'close': mainWindow?.close(); break; + case 'hide': + mainWindow?.hide(); + break; case 'minimize': mainWindow?.minimize(); break; @@ -339,8 +347,7 @@ function createWindow() { menu.append(new MenuItem({ label: 'Copy', role: 'copy' })); menu.append(new MenuItem({ label: 'Paste', role: 'paste' })); - if (mainWindow) - menu.popup({ window: mainWindow }); + if (mainWindow) menu.popup({ window: mainWindow }); }); } @@ -353,7 +360,7 @@ const checkEnvironmentVariables = () => { 'SlimeVR', `You have environment variables ${set.join(', ')} set, which may cause the SlimeVR Server to fail to launch properly.` ); - app.exit(0); + app.quit(); } }; @@ -380,36 +387,60 @@ const spawnServer = async () => { 'SlimeVR', `Couldn't find a compatible Java version, please download Java 17 or higher` ); - app.exit(0); + app.quit() return; } logger.info({ javaBin, serverJar }, 'Found Java and server jar'); - - const process = spawn(javaBin, ['-Xmx128M', '-jar', serverJar, 'run']); - - process.stdout?.on('data', (message) => { - mainWindow?.webContents.send(IPC_CHANNELS.SERVER_STATUS, { - message: message.toString(), - type: 'stdout', - } satisfies ServerStatusEvent); + const platform = getPlatform(); + const serverWorkdir = getServerDataFolder() + const serverProcess = spawn(javaBin, ['-Xmx128M', '-jar', serverJar, 'run'], { + cwd: serverWorkdir, + shell: false, + env: + platform === 'windows' + ? { + ...process.env, + APPDATA: app.getPath('appData'), + LOCALAPPDATA: process.env['USERPROFILE'] ? path.join(process.env['USERPROFILE'], 'AppData', 'Local') : undefined, + } + : undefined, }); - process.stderr?.on('data', (message) => { - mainWindow?.webContents.send(IPC_CHANNELS.SERVER_STATUS, { - message: message.toString(), - type: 'stderr', - } satisfies ServerStatusEvent); + const sendToWindow = (event: ServerStatusEvent) => { + if (mainWindow && !mainWindow.webContents.isDestroyed()) { + mainWindow.webContents.send(IPC_CHANNELS.SERVER_STATUS, event); + } + }; + + serverProcess.stdout?.on('data', (message) => { + sendToWindow({ message: message.toString(), type: 'stdout' }); }); + serverProcess.stderr?.on('data', (message) => { + sendToWindow({ message: message.toString(), type: 'stderr' }); + }); + + serverProcess.on('error', (err) => { + logger.info({ err }, 'Error launching the java server'); + if (!isQuitting) app.quit(); + }) + + serverProcess.on('exit', () => { + logger.info('Server process exiting'); + }) + + const exited = new Promise((resolve) => serverProcess.once('exit', resolve)); + return { - process: process, - close: () => { - process.kill('SIGTERM'); - }, + process: serverProcess, + close: () => serverProcess.kill(), + waitForExit: () => exited, }; }; +let isQuitting = false; + app.whenReady().then(async () => { // Register protocol handler for app:// scheme to handle assets with leading slashes protocol.handle('app', (request) => { @@ -426,23 +457,21 @@ app.whenReady().then(async () => { logger.info('SlimeVR started!'); app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit(); - } + app.quit(); }); - process.on('exit', () => { - server?.close(); - }); - - app.on('before-quit', async () => { + app.on('before-quit', async (event) => { + if (isQuitting) return; + isQuitting = true; + event.preventDefault(); logger.info('App quitting, saving...'); server?.close(); + await server?.waitForExit(); stores.settings.save(); stores.cache.save(); - discordPresence.destroy(); - await saveWindowState(); + await closeLogger(); + app.exit(0); }); }); diff --git a/gui/electron/main/logger.ts b/gui/electron/main/logger.ts index b870b7169..e2f7d60a0 100644 --- a/gui/electron/main/logger.ts +++ b/gui/electron/main/logger.ts @@ -24,3 +24,11 @@ const transport = pino.transport({ }); export const logger = pino(transport); + +export const closeLogger = () => + new Promise((resolve) => { + logger.flush(() => { + transport.once('close', resolve); + transport.end(); + }); + }); diff --git a/gui/electron/main/paths.ts b/gui/electron/main/paths.ts index 437901022..da9dfb97c 100644 --- a/gui/electron/main/paths.ts +++ b/gui/electron/main/paths.ts @@ -103,12 +103,12 @@ export const findSystemJRE = async (sharedDir: string) => { export const findServerJar = () => { const paths = [ options.path ? path.resolve(options.path) : undefined, + app.isPackaged ? path.resolve(process.resourcesPath) : undefined, // AppImage passes the fakeroot in `APPDIR` env var. process.env['APPDIR'] ? path.resolve(join(process.env['APPDIR'], 'usr/share/slimevr/')) : undefined, path.dirname(app.getPath('exe')), - // For flatpack container path.resolve('/app/share/slimevr/'), path.resolve('/usr/share/slimevr/'), diff --git a/gui/electron/preload/index.ts b/gui/electron/preload/index.ts index e9682ca0d..376393c94 100644 --- a/gui/electron/preload/index.ts +++ b/gui/electron/preload/index.ts @@ -12,6 +12,7 @@ contextBridge.exposeInMainWorld('electronAPI', { openUrl: (url) => ipcRenderer.invoke(IPC_CHANNELS.OPEN_URL, url), osStats: () => ipcRenderer.invoke(IPC_CHANNELS.OS_STATS), close: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_ACTIONS, 'close'), + hide: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_ACTIONS, 'hide'), minimize: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_ACTIONS, 'minimize'), maximize: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_ACTIONS, 'maximize'), getStorage: async (type) => { diff --git a/gui/electron/preload/interface.d.ts b/gui/electron/preload/interface.d.ts index 03c47a1f7..e7822f9a5 100644 --- a/gui/electron/preload/interface.d.ts +++ b/gui/electron/preload/interface.d.ts @@ -43,6 +43,7 @@ export interface IElectronAPI { openLogsFolder: () => Promise; openConfigFolder: () => Promise; close: () => void; + hide: () => void; minimize: () => void; maximize: () => void; showDecorations: (decorations: boolean) => void; diff --git a/gui/electron/shared.ts b/gui/electron/shared.ts index 1364550bd..5abe21876 100644 --- a/gui/electron/shared.ts +++ b/gui/electron/shared.ts @@ -25,7 +25,7 @@ export const IPC_CHANNELS = { export interface IpcInvokeMap { [IPC_CHANNELS.OPEN_URL]: (url: string) => void; [IPC_CHANNELS.OS_STATS]: () => Promise; - [IPC_CHANNELS.WINDOW_ACTIONS]: (action: 'close' | 'minimize' | 'maximize') => void; + [IPC_CHANNELS.WINDOW_ACTIONS]: (action: 'close' | 'minimize' | 'maximize' | 'hide') => void; [IPC_CHANNELS.LOG]: (type: 'info' | 'error' | 'warn', ...args: unknown[]) => void; [IPC_CHANNELS.OPEN_DIALOG]: ( options: OpenDialogOptions diff --git a/gui/src/components/TopBar.tsx b/gui/src/components/TopBar.tsx index e15006485..60d7b2d6e 100644 --- a/gui/src/components/TopBar.tsx +++ b/gui/src/components/TopBar.tsx @@ -81,7 +81,7 @@ export function TopBar({ } if (config?.useTray && !dontTray) { - electron.api.minimize(); + electron.api.hide(); } else if ( config?.connectedTrackersWarning && connectedIMUTrackers.filter( diff --git a/server/android/build.gradle.kts b/server/android/build.gradle.kts index e48cd0c3f..6eba1c723 100644 --- a/server/android/build.gradle.kts +++ b/server/android/build.gradle.kts @@ -160,7 +160,7 @@ android { // adds an offset of the version code as we might do apk releases in the middle of actual // releases if we failed on bundling or stuff - val versionCodeOffset = 4 + val versionCodeOffset = 5 // Defines the version number of your app. versionCode = (extra["gitVersionCode"] as? Int)?.plus(versionCodeOffset) ?: 0 diff --git a/server/core/build.gradle.kts b/server/core/build.gradle.kts index 0f34ebb91..6736c72f3 100644 --- a/server/core/build.gradle.kts +++ b/server/core/build.gradle.kts @@ -72,7 +72,7 @@ dependencies { implementation("org.apache.commons:commons-lang3:3.20.0") implementation("org.apache.commons:commons-collections4:4.5.0") - implementation("com.illposed.osc:javaosc-core:0.9") + implementation("com.illposed.osc:javaosc-core:0.8") implementation("org.java-websocket:Java-WebSocket:1.+") implementation("com.melloware:jintellitype:1.+") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0") diff --git a/server/core/src/main/java/dev/slimevr/posestreamer/BVHRecorder.kt b/server/core/src/main/java/dev/slimevr/posestreamer/BVHRecorder.kt index 8fe09c92b..89d4959d8 100644 --- a/server/core/src/main/java/dev/slimevr/posestreamer/BVHRecorder.kt +++ b/server/core/src/main/java/dev/slimevr/posestreamer/BVHRecorder.kt @@ -19,6 +19,10 @@ class BVHRecorder(server: VRServer) { val file = if (filePath.isDirectory()) { getBvhFile(filePath) ?: return } else { + if (filePath.extension != "bvh") { + LogManager.severe("[BVH] Invalid file extension for bvh file \"${filePath}\".") + return + } filePath } diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/UDPDevice.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/UDPDevice.kt index bed58741c..7eac78d83 100644 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/UDPDevice.kt +++ b/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/UDPDevice.kt @@ -44,6 +44,18 @@ class UDPDevice( @JvmField var lastPacketNumber: Long = -1 + @JvmField + var totalPacketsReceived: Int = 0 + + @JvmField + var acceptedPackets: Int = 0 + + @JvmField + var lastPacketCounterReset: Long = System.currentTimeMillis() + + val packetLossPercent: Float + get() = if (totalPacketsReceived == 0) 0f else (1f - acceptedPackets.toFloat() / totalPacketsReceived.toFloat()) + @JvmField var protocol: NetworkProtocol? = null @@ -68,9 +80,25 @@ class UDPDevice( var firmwareFeatures = FirmwareFeatures() fun isNextPacket(packetId: Long): Boolean { - if (packetId != 0L && packetId <= lastPacketNumber) return false - lastPacketNumber = packetId - return true + val now = System.currentTimeMillis() + if (now - lastPacketCounterReset >= 10_000L) { + totalPacketsReceived = 0 + acceptedPackets = 0 + lastPacketCounterReset = now + } + totalPacketsReceived++ + val accepted = packetId == 0L || packetId > lastPacketNumber + if (accepted) { + lastPacketNumber = packetId + acceptedPackets++ + } + val lost = totalPacketsReceived - acceptedPackets + trackers.values.forEach { + it.packetsReceived = totalPacketsReceived + it.packetsLost = lost + it.packetLoss = packetLossPercent + } + return accepted } override fun toString(): String = "udp:/$ipAddress"