diff --git a/.github/scripts/set-opennow-version.mjs b/.github/scripts/set-opennow-version.mjs new file mode 100644 index 00000000..45498b42 --- /dev/null +++ b/.github/scripts/set-opennow-version.mjs @@ -0,0 +1,31 @@ +import fs from "node:fs"; + +const version = process.argv[2] || process.env.RELEASE_VERSION; + +if (!version) { + console.error("Usage: node .github/scripts/set-opennow-version.mjs "); + process.exit(1); +} + +const semverPattern = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +if (!semverPattern.test(version)) { + console.error(`Invalid semver version: ${version}`); + process.exit(1); +} + +const updateJson = (path, updater) => { + const data = JSON.parse(fs.readFileSync(path, "utf8")); + updater(data); + fs.writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`); +}; + +updateJson("opennow-stable/package.json", (pkg) => { + pkg.version = version; +}); + +updateJson("opennow-stable/package-lock.json", (lockfile) => { + lockfile.version = version; + if (lockfile.packages?.[""]) { + lockfile.packages[""].version = version; + } +}); diff --git a/.github/workflows/auto-build.yml b/.github/workflows/auto-build.yml index 517bffc6..8c044df2 100644 --- a/.github/workflows/auto-build.yml +++ b/.github/workflows/auto-build.yml @@ -106,3 +106,63 @@ jobs: opennow-stable/dist-release/*.AppImage opennow-stable/dist-release/*.deb if-no-files-found: error + + android-apk: + name: android-apk + runs-on: ubuntu-24.04 + + defaults: + run: + working-directory: opennow-stable + + env: + npm_config_audit: "false" + npm_config_fund: "false" + ANDROID_COMPILE_SDK: "36" + ANDROID_BUILD_TOOLS: "36.0.0" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: "**/package-lock.json" + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: gradle + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Install Android SDK components + run: | + yes | sdkmanager --licenses >/dev/null + sdkmanager \ + "platform-tools" \ + "platforms;android-${ANDROID_COMPILE_SDK}" \ + "build-tools;${ANDROID_BUILD_TOOLS}" + + - name: Install dependencies + run: npm ci --prefer-offline --no-audit --progress=false + + - name: Build web assets and sync Android project + run: npm run cap:sync:android + + - name: Build debug APK + working-directory: opennow-stable/android + run: ./gradlew --no-daemon assembleDebug + + - name: Upload Android APK artifact + uses: actions/upload-artifact@v4 + with: + name: opennow-stable-android-apk + path: opennow-stable/android/app/build/outputs/apk/debug/app-debug.apk + if-no-files-found: error diff --git a/README.md b/README.md index 235d2389..28450e59 100644 --- a/README.md +++ b/README.md @@ -53,13 +53,14 @@ ## Overview -OpenNOW is a community-built Electron app for playing GeForce NOW from an open-source desktop client. The active implementation lives in [`opennow-stable/`](opennow-stable) and uses Electron, React, and TypeScript across the main, preload, and renderer processes. +OpenNOW is a community-built GeForce NOW client. The active implementation lives in [`opennow-stable/`](opennow-stable) and now supports two runtime targets from the same React renderer: Electron for desktop and Capacitor for Android. The project aims to give players a transparent, customizable alternative to the official client without hiding the technical parts from contributors. ## Highlights - Open-source desktop client for Windows, macOS, and Linux +- Experimental Android target via Capacitor using the shared React renderer in a WebView - Catalog and public game browsing with search and library-aware session handling - Stream controls for codec, resolution, FPS, aspect ratio, region, and quality preferences - In-stream diagnostics overlay with latency, packet loss, decode, and render stats @@ -81,6 +82,8 @@ Current packaging targets: | Linux x64 | `AppImage`, `deb` | | Linux ARM64 | `AppImage`, `deb` | +For Android testing, GitHub Actions now builds a debug APK artifact for manual workflow runs, plus pull requests and pushes to `main`/`dev` when the `auto-build` path filters match (`opennow-stable/**` or `.github/workflows/auto-build.yml`). Download it from the workflow artifacts; it is intended for testing, not release distribution. + ### Develop Locally From the repository root: @@ -122,15 +125,43 @@ For a fuller setup guide, see [docs/development.md](docs/development.md). ## Architecture At A Glance -OpenNOW is split into three Electron layers: +OpenNOW uses a shared renderer with thin platform adapters: | Layer | Tech | Responsibility | | --- | --- | --- | -| Main | Electron + Node.js | OAuth, CloudMatch/session orchestration, signaling, caching, local file handling | -| Preload | Electron `contextBridge` | Safe IPC bridge between the app shell and UI | -| Renderer | React + TypeScript | Login flow, browsing, settings, WebRTC playback, diagnostics, controls | +| Main | Electron + Node.js | Desktop-only OAuth, IPC, local filesystem/media, cache, window management | +| Preload | Electron `contextBridge` | Desktop bridge exposing the existing OpenNOW API surface | +| Renderer | React + TypeScript | Shared login flow, browsing, settings, WebRTC playback, diagnostics, controls | +| Capacitor Android | Capacitor + WebView | Android shell, localhost WebView auth interception, Preferences/filesystem storage, browser-based signaling | + +The code lives under [`opennow-stable/src/`](opennow-stable/src), with shared TypeScript types and platform-neutral helpers in [`opennow-stable/src/shared/`](opennow-stable/src/shared). The renderer now consumes [`src/renderer/src/platform/`](opennow-stable/src/renderer/src/platform/) instead of hard-coding `window.openNow`. + +## Android Status + +The Android target is an initial pass intended to run the core OpenNOW flow inside a Capacitor WebView. Current Android support includes: + +- auth session restore +- login via native Android WebView interception while preserving the exact desktop redirect URI contract (`http://localhost:`) +- provider and region loading +- main/library/public game catalog fetches +- session create, poll, claim, and stop +- direct signaling from the WebView +- settings persistence +- screenshots and recordings stored in the app data directory + +Known Android limitations in this pass: + +- no desktop-style quit action +- no pointer-lock toggle semantics +- no Electron log export or cache deletion flow +- no show-in-folder integration for media +- screenshot export/save-as remains desktop-only +- some desktop shortcut UX is hidden or non-applicable on touch devices + +Android auth now mirrors the desktop localhost redirect contract by opening NVIDIA login in a native Android WebView and intercepting the navigation to `http://localhost:` inside the Android shell instead of using deep links or running a real loopback callback server. + +CI also produces a testable Android APK artifact from the Capacitor target so reviewers can install and validate the current branch without making a release build. -The code lives under [`opennow-stable/src/`](opennow-stable/src), with shared TypeScript types and IPC contracts in [`opennow-stable/src/shared/`](opennow-stable/src/shared). ## Contributing diff --git a/docs/development.md b/docs/development.md index c2170597..43d4fcab 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,11 +1,14 @@ # Development Guide -This guide covers the active Electron-based OpenNOW client in [`opennow-stable/`](../opennow-stable). +This guide covers the active OpenNOW app in [`opennow-stable/`](../opennow-stable), including the existing Electron desktop target and the new Capacitor Android target. ## Prerequisites - Node.js 22 or newer - npm +- Java 21 JDK +- Android SDK command-line tools or Android Studio +- `ANDROID_SDK_ROOT` set to your local Android SDK path when building the APK outside Android Studio - A GeForce NOW account for end-to-end testing ## Getting Started @@ -35,12 +38,20 @@ npm run dev npm run preview npm run typecheck npm run build +npm run build:web +npm run cap:sync:android +npm run cap:open:android npm run dist npm run dist:signed ``` +GitHub Actions also builds a testable Android debug APK artifact in the `auto-build` workflow for manual runs, plus pull requests and pushes to `main`/`dev` when the workflow path filters match (`opennow-stable/**` or `.github/workflows/auto-build.yml`). + ## Workspace Layout +The Android shell lives in [`opennow-stable/android/`](../opennow-stable/android), Capacitor config lives in [`opennow-stable/capacitor.config.ts`](../opennow-stable/capacitor.config.ts), and the renderer platform abstraction lives in [`opennow-stable/src/renderer/src/platform/`](../opennow-stable/src/renderer/src/platform/). + + ```text opennow-stable/ ├── src/ @@ -91,7 +102,8 @@ The renderer is a React app responsible for: - Browsing the catalog and public listings - Managing stream launch state and session recovery - Rendering the WebRTC stream -- Handling controller input, shortcuts, stats overlay, screenshots, recordings, and settings UI +- Handling controller input, stats overlay, screenshots, recordings, and settings UI +- Choosing the active runtime implementation through `src/renderer/src/platform/` Key entry points: @@ -155,6 +167,12 @@ Current build matrix: | Linux x64 | `AppImage`, `deb` | | Linux ARM64 | `AppImage`, `deb` | +Additional CI output: + +| Target | Output | +| --- | --- | +| Android testing | Debug APK artifact uploaded from `auto-build` | + ## Notes For Contributors - The active app is the Electron client. If you see older references to previous implementations, prefer `opennow-stable/`. @@ -162,3 +180,50 @@ Current build matrix: - Before opening a PR, run `npm run typecheck` and `npm run build`. For contribution workflow details, see [`.github/CONTRIBUTING.md`](../.github/CONTRIBUTING.md). + + +## Android Workflow + +Local Android toolchain prerequisites: + +- Android platform `android-36` +- Android build-tools `36.0.0` +- Android platform-tools +- Accepted Android SDK licenses + +If you are using the command-line SDK tools, install the same Android components that CI installs: + +```bash +yes | sdkmanager --licenses +sdkmanager --install \ + "platform-tools" \ + "platforms;android-36" \ + "build-tools;36.0.0" +``` + +Build and sync web assets into the Android project: + +```bash +cd opennow-stable +npm run cap:sync:android +``` + +Build a local test APK: + +```bash +cd opennow-stable +npm run cap:sync:android +cd android +./gradlew assembleDebug +``` + +Open the Android project in Android Studio: + +```bash +cd opennow-stable +npm run cap:open:android +``` + +Current Android support is limited to the core cloud-gaming path. Android login now follows the same localhost redirect contract as desktop (`http://localhost:` for both authorize and token exchange), but the Android shell intercepts that navigation inside a native WebView instead of hosting a real localhost callback server. Desktop-specific features such as quit app, pointer-lock toggles, log export, cache deletion, show-in-folder actions, and screenshot save-as are intentionally gated or unavailable on Android in this pass. + +For CI-based testing, use the APK artifact uploaded by the `auto-build` workflow. It is a debug/testing package and is not release-signed. diff --git a/opennow-stable/android/.gitignore b/opennow-stable/android/.gitignore new file mode 100644 index 00000000..48354a3d --- /dev/null +++ b/opennow-stable/android/.gitignore @@ -0,0 +1,101 @@ +# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# Cordova plugins for Capacitor +capacitor-cordova-android-plugins + +# Copied web assets +app/src/main/assets/public + +# Generated Config files +app/src/main/assets/capacitor.config.json +app/src/main/assets/capacitor.plugins.json +app/src/main/res/xml/config.xml diff --git a/opennow-stable/android/app/.gitignore b/opennow-stable/android/app/.gitignore new file mode 100644 index 00000000..043df802 --- /dev/null +++ b/opennow-stable/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/opennow-stable/android/app/build.gradle b/opennow-stable/android/app/build.gradle new file mode 100644 index 00000000..127a6b69 --- /dev/null +++ b/opennow-stable/android/app/build.gradle @@ -0,0 +1,70 @@ +apply plugin: 'com.android.application' + +import groovy.json.JsonSlurper + +def packageJson = new JsonSlurper().parse(rootProject.file('../package.json')) +def packageVersionName = ((packageJson.version ?: '1.0.0') as String).trim() +def versionParts = packageVersionName.tokenize('.').collect { token -> + def numeric = (token =~ /(\d+)/) + numeric.find() ? Integer.parseInt(numeric.group(1)) : 0 +} +while (versionParts.size() < 3) { + versionParts << 0 +} +def computedPackageVersionCode = (versionParts[0] * 100000000) + (versionParts[1] * 10000) + versionParts[2] +def androidVersionCodeOverride = (project.findProperty('androidVersionCode') ?: '').toString().trim() +def packageVersionCode = androidVersionCodeOverride ? Integer.parseInt(androidVersionCodeOverride) : computedPackageVersionCode + +android { + namespace = "com.opencloudgaming.opennow" + compileSdk = rootProject.ext.compileSdkVersion + defaultConfig { + applicationId "com.opencloudgaming.opennow" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode packageVersionCode + versionName packageVersionName + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + aaptOptions { + // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. + // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 + ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + } + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +repositories { + flatDir{ + dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" + implementation "androidx.core:core:$androidxCoreVersion" + implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + implementation project(':capacitor-android') + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" + implementation project(':capacitor-cordova-android-plugins') +} + +apply from: 'capacitor.build.gradle' + +try { + def servicesJSON = file('google-services.json') + if (servicesJSON.text) { + apply plugin: 'com.google.gms.google-services' + } +} catch(Exception e) { + logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") +} diff --git a/opennow-stable/android/app/capacitor.build.gradle b/opennow-stable/android/app/capacitor.build.gradle new file mode 100644 index 00000000..8929a1b2 --- /dev/null +++ b/opennow-stable/android/app/capacitor.build.gradle @@ -0,0 +1,23 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN + +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } +} + +apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" +dependencies { + implementation project(':capacitor-app') + implementation project(':capacitor-device') + implementation project(':capacitor-filesystem') + implementation project(':capacitor-preferences') + implementation project(':capacitor-status-bar') + +} + + +if (hasProperty('postBuildExtras')) { + postBuildExtras() +} diff --git a/opennow-stable/android/app/proguard-rules.pro b/opennow-stable/android/app/proguard-rules.pro new file mode 100644 index 00000000..f1b42451 --- /dev/null +++ b/opennow-stable/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/opennow-stable/android/app/src/androidTest/java/com/opencloudgaming/opennow/ExampleInstrumentedTest.java b/opennow-stable/android/app/src/androidTest/java/com/opencloudgaming/opennow/ExampleInstrumentedTest.java new file mode 100644 index 00000000..f6720102 --- /dev/null +++ b/opennow-stable/android/app/src/androidTest/java/com/opencloudgaming/opennow/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.opencloudgaming.opennow; + +import static org.junit.Assert.*; + +import android.content.Context; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + assertEquals("com.opencloudgaming.opennow", appContext.getPackageName()); + } +} diff --git a/opennow-stable/android/app/src/main/AndroidManifest.xml b/opennow-stable/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..5660b8af --- /dev/null +++ b/opennow-stable/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/opennow-stable/android/app/src/main/java/com/opencloudgaming/opennow/LocalhostAuthPlugin.java b/opennow-stable/android/app/src/main/java/com/opencloudgaming/opennow/LocalhostAuthPlugin.java new file mode 100644 index 00000000..c3c8753a --- /dev/null +++ b/opennow-stable/android/app/src/main/java/com/opencloudgaming/opennow/LocalhostAuthPlugin.java @@ -0,0 +1,186 @@ +package com.opencloudgaming.opennow; + +import android.app.Activity; +import android.content.Intent; +import android.os.SystemClock; + +import androidx.activity.result.ActivityResult; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.ActivityCallback; +import com.getcapacitor.annotation.CapacitorPlugin; + +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +@CapacitorPlugin(name = "LocalhostAuth") +public class LocalhostAuthPlugin extends Plugin { + private static final int[] PREFERRED_PORTS = {2259, 6460, 7119, 8870, 9096}; + + private final Object authLock = new Object(); + private String activeCallbackId; + + @PluginMethod + public void startLogin(PluginCall call) { + String authUrl = call.getString("authUrl"); + if (authUrl == null || authUrl.isEmpty()) { + call.reject("Missing authUrl"); + return; + } + + int port = call.getInt("port", PREFERRED_PORTS[0]); + int timeoutMs = call.getInt("timeoutMs", 180000); + + synchronized (authLock) { + if (activeCallbackId != null) { + call.reject("Login is already in progress"); + return; + } + activeCallbackId = call.getCallbackId(); + } + + Intent intent = new Intent(getContext(), LoginActivity.class); + intent.putExtra(LoginActivity.EXTRA_AUTH_URL, authUrl); + intent.putExtra(LoginActivity.EXTRA_EXPECTED_PORT, port); + intent.putExtra(LoginActivity.EXTRA_TIMEOUT_MS, timeoutMs); + + try { + startActivityForResult(call, intent, "handleLoginResult"); + } catch (RuntimeException error) { + clearActiveLogin(call.getCallbackId()); + call.reject("Unable to open the sign-in window: " + error.getMessage()); + } + } + + @PluginMethod + public void tcpPing(PluginCall call) { + String rawUrl = call.getString("url"); + if (rawUrl == null || rawUrl.isEmpty()) { + call.reject("Missing url"); + return; + } + + int timeoutMs = Math.max(500, call.getInt("timeoutMs", 3000)); + int samples = Math.max(1, Math.min(5, call.getInt("samples", 3))); + boolean warmup = call.getBoolean("warmup", true); + + new Thread(() -> { + try { + URL url = new URL(rawUrl); + String host = url.getHost(); + int port = url.getPort(); + if (port <= 0) { + port = "http".equalsIgnoreCase(url.getProtocol()) ? 80 : 443; + } + + if (host == null || host.isEmpty()) { + resolveTcpPing(call, null, "Invalid URL host"); + return; + } + + if (warmup) { + tcpConnectMs(host, port, timeoutMs); + } + + List timings = new ArrayList<>(); + for (int index = 0; index < samples; index++) { + if (index > 0) { + Thread.sleep(100); + } + Long timing = tcpConnectMs(host, port, timeoutMs); + if (timing != null) { + timings.add(timing); + } + } + + if (timings.isEmpty()) { + resolveTcpPing(call, null, "All ping tests failed"); + return; + } + + long total = 0; + for (Long timing : timings) { + total += timing; + } + resolveTcpPing(call, Math.round((double) total / timings.size()), null); + } catch (Exception error) { + resolveTcpPing(call, null, error.getMessage() != null ? error.getMessage() : "Ping failed"); + } + }, "OpenNOW-TcpPing").start(); + } + + @ActivityCallback + private void handleLoginResult(PluginCall call, ActivityResult result) { + if (call == null) { + clearActiveLogin(null); + return; + } + + clearActiveLogin(call.getCallbackId()); + + Intent data = result.getData(); + if (result.getResultCode() == LoginActivity.RESULT_AUTH_SUCCESS && data != null) { + String code = data.getStringExtra(LoginActivity.EXTRA_RESULT_CODE); + if (code != null && !code.isEmpty()) { + JSObject payload = new JSObject(); + payload.put("code", code); + payload.put("redirectUri", data.getStringExtra(LoginActivity.EXTRA_RESULT_REDIRECT_URI)); + call.resolve(payload); + return; + } + } + + String message = data != null ? data.getStringExtra(LoginActivity.EXTRA_RESULT_ERROR) : null; + if (message == null || message.isEmpty()) { + message = result.getResultCode() == Activity.RESULT_CANCELED + ? "Login was cancelled before the OAuth callback completed" + : "Authorization failed"; + } + call.reject(message); + } + + private void clearActiveLogin(String callbackId) { + synchronized (authLock) { + if (callbackId == null || callbackId.equals(activeCallbackId)) { + activeCallbackId = null; + } + } + } + + private Long tcpConnectMs(String host, int port, int timeoutMs) { + long startedAt = SystemClock.elapsedRealtime(); + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(host, port), timeoutMs); + return SystemClock.elapsedRealtime() - startedAt; + } catch (Exception ignored) { + return null; + } + } + + private void resolveTcpPing(PluginCall call, Long pingMs, String error) { + getActivity().runOnUiThread(() -> { + JSObject payload = new JSObject(); + if (pingMs != null) { + payload.put("pingMs", pingMs); + } + if (error != null && !error.isEmpty()) { + payload.put("error", error); + } + call.resolve(payload); + }); + } + + @Override + protected void handleOnDestroy() { + synchronized (authLock) { + activeCallbackId = null; + } + super.handleOnDestroy(); + } +} diff --git a/opennow-stable/android/app/src/main/java/com/opencloudgaming/opennow/LoginActivity.java b/opennow-stable/android/app/src/main/java/com/opencloudgaming/opennow/LoginActivity.java new file mode 100644 index 00000000..e0c773b3 --- /dev/null +++ b/opennow-stable/android/app/src/main/java/com/opencloudgaming/opennow/LoginActivity.java @@ -0,0 +1,159 @@ +package com.opencloudgaming.opennow; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Intent; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.ViewGroup; +import android.webkit.CookieManager; +import android.webkit.WebResourceRequest; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; + +public class LoginActivity extends AppCompatActivity { + public static final String EXTRA_AUTH_URL = "authUrl"; + public static final String EXTRA_EXPECTED_PORT = "expectedPort"; + public static final String EXTRA_TIMEOUT_MS = "timeoutMs"; + public static final String EXTRA_RESULT_CODE = "code"; + public static final String EXTRA_RESULT_ERROR = "error"; + public static final String EXTRA_RESULT_REDIRECT_URI = "redirectUri"; + public static final int RESULT_AUTH_SUCCESS = Activity.RESULT_FIRST_USER + 1; + + private final Handler handler = new Handler(Looper.getMainLooper()); + private final Runnable timeoutRunnable = () -> finishWithError("Timed out waiting for OAuth callback"); + + private WebView webView; + private int expectedPort; + private boolean completed; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + String authUrl = getIntent().getStringExtra(EXTRA_AUTH_URL); + expectedPort = getIntent().getIntExtra(EXTRA_EXPECTED_PORT, 2259); + int timeoutMs = getIntent().getIntExtra(EXTRA_TIMEOUT_MS, 180000); + + if (authUrl == null || authUrl.isEmpty()) { + finishWithError("Missing authUrl"); + return; + } + + handler.postDelayed(timeoutRunnable, Math.max(1000, timeoutMs)); + webView = new WebView(this); + webView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + setContentView(webView); + configureWebView(webView); + webView.loadUrl(authUrl); + } + + @SuppressLint("SetJavaScriptEnabled") + private void configureWebView(WebView view) { + CookieManager cookieManager = CookieManager.getInstance(); + cookieManager.setAcceptCookie(true); + cookieManager.setAcceptThirdPartyCookies(view, true); + + WebSettings settings = view.getSettings(); + settings.setJavaScriptEnabled(true); + settings.setDomStorageEnabled(true); + settings.setDatabaseEnabled(true); + settings.setAllowContentAccess(true); + settings.setAllowFileAccess(false); + settings.setJavaScriptCanOpenWindowsAutomatically(true); + settings.setSupportMultipleWindows(false); + settings.setLoadWithOverviewMode(true); + settings.setUseWideViewPort(true); + + view.setWebViewClient(new WebViewClient() { + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + return maybeHandleRedirect(request != null ? request.getUrl() : null); + } + + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + return maybeHandleRedirect(url != null ? Uri.parse(url) : null); + } + + @Override + public void onPageStarted(WebView view, String url, Bitmap favicon) { + maybeHandleRedirect(url != null ? Uri.parse(url) : null); + super.onPageStarted(view, url, favicon); + } + }); + } + + private boolean maybeHandleRedirect(@Nullable Uri uri) { + if (completed || uri == null) { + return false; + } + + String host = uri.getHost(); + if (!"http".equalsIgnoreCase(uri.getScheme()) || host == null || !"localhost".equalsIgnoreCase(host) || uri.getPort() != expectedPort) { + return false; + } + + String error = uri.getQueryParameter("error"); + String code = uri.getQueryParameter("code"); + if (code != null && !code.isEmpty()) { + finishWithCode(code, "http://localhost:" + expectedPort); + } else { + finishWithError(error != null && !error.isEmpty() ? error : "Authorization failed"); + } + return true; + } + + private void finishWithCode(String code, String redirectUri) { + if (completed) { + return; + } + completed = true; + handler.removeCallbacks(timeoutRunnable); + Intent data = new Intent(); + data.putExtra(EXTRA_RESULT_CODE, code); + data.putExtra(EXTRA_RESULT_REDIRECT_URI, redirectUri); + setResult(RESULT_AUTH_SUCCESS, data); + finish(); + } + + private void finishWithError(String message) { + if (completed) { + return; + } + completed = true; + handler.removeCallbacks(timeoutRunnable); + Intent data = new Intent(); + data.putExtra(EXTRA_RESULT_ERROR, message); + setResult(Activity.RESULT_CANCELED, data); + finish(); + } + + @Override + public void onBackPressed() { + if (webView != null && webView.canGoBack()) { + webView.goBack(); + return; + } + finishWithError("Login was cancelled before the OAuth callback completed"); + } + + @Override + protected void onDestroy() { + handler.removeCallbacks(timeoutRunnable); + if (webView != null) { + webView.stopLoading(); + webView.setWebViewClient(null); + webView.destroy(); + webView = null; + } + super.onDestroy(); + } +} diff --git a/opennow-stable/android/app/src/main/java/com/opencloudgaming/opennow/MainActivity.java b/opennow-stable/android/app/src/main/java/com/opencloudgaming/opennow/MainActivity.java new file mode 100644 index 00000000..615d51df --- /dev/null +++ b/opennow-stable/android/app/src/main/java/com/opencloudgaming/opennow/MainActivity.java @@ -0,0 +1,42 @@ +package com.opencloudgaming.opennow; + +import android.graphics.Color; +import android.os.Bundle; +import android.webkit.WebView; + +import androidx.core.splashscreen.SplashScreen; + +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity { + @Override + public void onCreate(Bundle savedInstanceState) { + SplashScreen.installSplashScreen(this); + registerPlugin(LocalhostAuthPlugin.class); + registerPlugin(OpenNowAndroidPlugin.class); + super.onCreate(savedInstanceState); + getWindow().getDecorView().setBackgroundColor(Color.BLACK); + getWindow().setStatusBarColor(Color.BLACK); + getWindow().setNavigationBarColor(Color.BLACK); + WebView webView = getBridge().getWebView(); + if (webView != null) { + webView.setBackgroundColor(Color.BLACK); + } + } + + @Override + public void onResume() { + super.onResume(); + if (OpenNowAndroidPlugin.isImmersiveFullscreenRequested()) { + OpenNowAndroidPlugin.applyImmersiveFullscreen(this, true); + } + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + if (hasFocus && OpenNowAndroidPlugin.isImmersiveFullscreenRequested()) { + OpenNowAndroidPlugin.applyImmersiveFullscreen(this, true); + } + } +} diff --git a/opennow-stable/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowAndroidPlugin.java b/opennow-stable/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowAndroidPlugin.java new file mode 100644 index 00000000..b5e81083 --- /dev/null +++ b/opennow-stable/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowAndroidPlugin.java @@ -0,0 +1,981 @@ +package com.opencloudgaming.opennow; + +import android.app.Activity; +import android.app.ActivityManager; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.Typeface; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.InputDevice; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.widget.FrameLayout; + +import java.util.ArrayList; +import java.util.List; + +import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.core.view.WindowInsetsControllerCompat; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +@CapacitorPlugin(name = "OpenNowAndroid") +public class OpenNowAndroidPlugin extends Plugin { + private static final String[] APP_ID_KEYS = { + "appId", + "app_id", + "id", + "launchAppId", + "launch_app_id", + "gfnAppId", + "gfn_app_id", + "nvidiaAppId", + "nvidia_app_id", + "cloudGameId", + "cloud_game_id", + "com.opencloudgaming.opennow.APP_ID", + "com.opencloudgaming.opennow.LAUNCH_APP_ID", + "com.opencloudgaming.opennow.extra.APP_ID", + "com.opencloudgaming.opennow.extra.LAUNCH_APP_ID" + }; + private static final String[] TITLE_KEYS = { + "title", + "name", + "game", + "gameTitle", + "game_title", + "romName", + "rom_name", + "label", + "com.opencloudgaming.opennow.TITLE", + "com.opencloudgaming.opennow.extra.TITLE", + "com.opencloudgaming.opennow.extra.GAME_TITLE" + }; + private static final String[] STORE_KEYS = { + "store", + "appStore", + "app_store", + "variantStore", + "variant_store", + "com.opencloudgaming.opennow.STORE", + "com.opencloudgaming.opennow.extra.STORE" + }; + private static final String[] SOURCE_KEYS = { + "source", + "frontend", + "launcher", + "caller", + "com.opencloudgaming.opennow.SOURCE", + "com.opencloudgaming.opennow.extra.SOURCE" + }; + + private View pointerCaptureView; + private NativeTouchControllerView nativeTouchControllerView; + private JSObject pendingLaunchIntent; + private int launchIntentSequence = 0; + private boolean pointerCaptureRequested = false; + private final Handler pointerCaptureHandler = new Handler(Looper.getMainLooper()); + private final Runnable pointerCaptureRefreshRunnable = new Runnable() { + @Override + public void run() { + if (!pointerCaptureRequested) { + return; + } + + View view = pointerCaptureView; + if (view != null && view.isAttachedToWindow()) { + requestNativePointerCapture(view); + } + pointerCaptureHandler.postDelayed(this, 250); + } + }; + private static volatile boolean immersiveFullscreenRequested = false; + private static int previousRequestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED; + private static boolean previousRequestedOrientationCaptured = false; + + @Override + public void load() { + super.load(); + pointerCaptureView = getBridge().getWebView(); + installPointerCaptureListener(pointerCaptureView); + } + + @PluginMethod + public void setImmersiveFullscreen(PluginCall call) { + boolean enabled = call.getBoolean("enabled", false); + getBridge().executeOnMainThread(() -> { + applyImmersiveFullscreen(enabled); + JSObject payload = new JSObject(); + payload.put("enabled", enabled); + call.resolve(payload); + }); + } + + @PluginMethod + public void setPointerCapture(PluginCall call) { + boolean enabled = call.getBoolean("enabled", false); + getBridge().executeOnMainThread(() -> { + JSObject payload = new JSObject(); + payload.put("supported", Build.VERSION.SDK_INT >= Build.VERSION_CODES.O); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + payload.put("enabled", false); + call.resolve(payload); + return; + } + + View view = pointerCaptureView != null ? pointerCaptureView : getBridge().getWebView(); + installPointerCaptureListener(view); + pointerCaptureRequested = enabled; + + if (enabled) { + requestNativePointerCapture(view); + schedulePointerCaptureRefresh(); + } else if (view.hasPointerCapture()) { + stopPointerCaptureRefresh(); + view.releasePointerCapture(); + } else { + stopPointerCaptureRefresh(); + } + + payload.put("enabled", view.hasPointerCapture()); + call.resolve(payload); + }); + } + + @PluginMethod + public void consumeLaunchIntent(PluginCall call) { + JSObject payload = new JSObject(); + payload.put("intent", pendingLaunchIntent); + pendingLaunchIntent = null; + call.resolve(payload); + } + + @PluginMethod + public void getPerformanceInfo(PluginCall call) { + Activity activity = getActivity(); + JSObject payload = new JSObject(); + ActivityManager.MemoryInfo memoryInfo = readMemoryInfo(activity); + if (memoryInfo != null) { + payload.put("totalMemBytes", memoryInfo.totalMem); + payload.put("availMemBytes", memoryInfo.availMem); + payload.put("thresholdBytes", memoryInfo.threshold); + payload.put("lowMemory", memoryInfo.lowMemory); + payload.put("liteTouchRecommended", memoryInfo.totalMem > 0 && memoryInfo.totalMem < 3L * 1024L * 1024L * 1024L); + } else { + payload.put("liteTouchRecommended", false); + } + call.resolve(payload); + } + + @PluginMethod + public void setNativeTouchControls(PluginCall call) { + boolean enabled = call.getBoolean("enabled", false); + Double sizeValue = call.getDouble("size", 1.0); + Double opacityValue = call.getDouble("opacity", 1.0); + String placement = call.getString("placement", "default"); + float size = clampFloat(sizeValue != null ? sizeValue.floatValue() : 1f, 0.72f, 1.35f); + float opacity = clampFloat(opacityValue != null ? opacityValue.floatValue() : 1f, 0.25f, 1f); + + getBridge().executeOnMainThread(() -> { + if (enabled) { + installNativeTouchController(size, opacity, placement); + } else { + removeNativeTouchController(); + } + + JSObject payload = new JSObject(); + payload.put("enabled", nativeTouchControllerView != null && nativeTouchControllerView.isAttachedToWindow()); + call.resolve(payload); + }); + } + + @Override + protected void handleOnNewIntent(Intent intent) { + super.handleOnNewIntent(intent); + JSObject payload = parseLaunchIntent(intent); + if (payload == null) { + return; + } + + pendingLaunchIntent = payload; + notifyListeners("launchIntent", payload, true); + } + + private void installNativeTouchController(float size, float opacity, String placement) { + Activity activity = getActivity(); + if (activity == null) { + return; + } + + ViewGroup root = activity.findViewById(android.R.id.content); + if (root == null) { + return; + } + + if (nativeTouchControllerView == null) { + nativeTouchControllerView = new NativeTouchControllerView(activity, this::emitNativeTouchGamepadState); + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ); + root.addView(nativeTouchControllerView, params); + } + nativeTouchControllerView.configure(size, opacity, placement); + nativeTouchControllerView.bringToFront(); + } + + private void removeNativeTouchController() { + NativeTouchControllerView view = nativeTouchControllerView; + if (view == null) { + return; + } + + view.disconnect(); + ViewGroup parent = view.getParent() instanceof ViewGroup ? (ViewGroup) view.getParent() : null; + if (parent != null) { + parent.removeView(view); + } + nativeTouchControllerView = null; + } + + private void emitNativeTouchGamepadState( + boolean connected, + int buttons, + float leftTrigger, + float rightTrigger, + float leftStickX, + float leftStickY, + float rightStickX, + float rightStickY, + long timestampMs + ) { + JSObject payload = new JSObject(); + payload.put("connected", connected); + payload.put("buttons", buttons); + payload.put("leftTrigger", leftTrigger); + payload.put("rightTrigger", rightTrigger); + payload.put("leftStickX", leftStickX); + payload.put("leftStickY", leftStickY); + payload.put("rightStickX", rightStickX); + payload.put("rightStickY", rightStickY); + payload.put("timestampMs", timestampMs); + notifyListeners("nativeTouchGamepad", payload); + } + + private void installPointerCaptureListener(View view) { + if (view == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + + pointerCaptureView = view; + view.setOnCapturedPointerListener((capturedView, event) -> { + if (!isSupportedPointerSource(event.getSource())) { + return false; + } + + int action = event.getActionMasked(); + long timestampMs = event.getEventTime(); + + if (action == MotionEvent.ACTION_HOVER_MOVE || action == MotionEvent.ACTION_MOVE) { + float dx = event.getAxisValue(MotionEvent.AXIS_RELATIVE_X); + float dy = event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y); + if (dx == 0f && dy == 0f) { + return false; + } + + JSObject payload = new JSObject(); + payload.put("dx", dx); + payload.put("dy", dy); + payload.put("timestampMs", timestampMs); + notifyListeners("nativeMouseMove", payload); + return true; + } + + if (action == MotionEvent.ACTION_BUTTON_PRESS || action == MotionEvent.ACTION_BUTTON_RELEASE) { + int button = mapMouseButton(event.getActionButton()); + if (button < 0) { + return false; + } + + JSObject payload = new JSObject(); + payload.put("button", button); + payload.put("pressed", action == MotionEvent.ACTION_BUTTON_PRESS); + payload.put("timestampMs", timestampMs); + notifyListeners("nativeMouseButton", payload); + return true; + } + + if (action == MotionEvent.ACTION_SCROLL) { + float vertical = event.getAxisValue(MotionEvent.AXIS_VSCROLL); + if (vertical == 0f) { + return false; + } + + JSObject payload = new JSObject(); + payload.put("delta", Math.round(vertical * 120f)); + payload.put("timestampMs", timestampMs); + notifyListeners("nativeMouseWheel", payload); + return true; + } + + return false; + }); + } + + private int mapMouseButton(int actionButton) { + switch (actionButton) { + case MotionEvent.BUTTON_PRIMARY: + return 0; + case MotionEvent.BUTTON_TERTIARY: + return 1; + case MotionEvent.BUTTON_SECONDARY: + return 2; + case MotionEvent.BUTTON_BACK: + return 3; + case MotionEvent.BUTTON_FORWARD: + return 4; + default: + return -1; + } + } + + private ActivityManager.MemoryInfo readMemoryInfo(Activity activity) { + if (activity == null) { + return null; + } + Object service = activity.getSystemService(Context.ACTIVITY_SERVICE); + if (!(service instanceof ActivityManager)) { + return null; + } + ActivityManager.MemoryInfo memoryInfo = new ActivityManager.MemoryInfo(); + ((ActivityManager) service).getMemoryInfo(memoryInfo); + return memoryInfo; + } + + private static float clampFloat(float value, float min, float max) { + if (Float.isNaN(value) || Float.isInfinite(value)) { + return min; + } + return Math.max(min, Math.min(max, value)); + } + + private boolean isSupportedPointerSource(int source) { + if ((source & InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE) { + return true; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + && (source & InputDevice.SOURCE_MOUSE_RELATIVE) == InputDevice.SOURCE_MOUSE_RELATIVE) { + return true; + } + // Android exposes some controller touchpads as SOURCE_TOUCHPAD. Capturing + // those as mice can interfere with the normal WebView gamepad path. + return false; + } + + private void requestNativePointerCapture(View view) { + if (view == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + + if (view.hasPointerCapture()) { + return; + } + + view.setFocusable(true); + view.setFocusableInTouchMode(true); + view.requestFocus(); + view.requestPointerCapture(); + } + + private void schedulePointerCaptureRefresh() { + pointerCaptureHandler.removeCallbacks(pointerCaptureRefreshRunnable); + pointerCaptureHandler.postDelayed(pointerCaptureRefreshRunnable, 250); + } + + private void stopPointerCaptureRefresh() { + pointerCaptureHandler.removeCallbacks(pointerCaptureRefreshRunnable); + } + + private JSObject parseLaunchIntent(Intent intent) { + if (intent == null) { + return null; + } + + Uri data = intent.getData(); + Bundle extras = intent.getExtras(); + String appId = normalizeAppId(firstNonEmpty(firstExtra(extras, APP_ID_KEYS), firstUriValue(data, APP_ID_KEYS))); + if (appId == null) { + appId = firstNumericUriPart(data); + } + + String title = firstNonEmpty(firstExtra(extras, TITLE_KEYS), firstUriValue(data, TITLE_KEYS)); + String store = firstNonEmpty(firstExtra(extras, STORE_KEYS), firstUriValue(data, STORE_KEYS)); + String source = firstNonEmpty(firstExtra(extras, SOURCE_KEYS), firstUriValue(data, SOURCE_KEYS)); + String action = trimToNull(intent.getAction()); + String dataString = trimToNull(intent.getDataString()); + + if (appId == null && title == null && store == null && source == null && dataString == null) { + return null; + } + + JSObject payload = new JSObject(); + payload.put("sequence", ++launchIntentSequence); + payload.put("receivedAtMs", System.currentTimeMillis()); + putIfPresent(payload, "action", action); + putIfPresent(payload, "data", dataString); + putIfPresent(payload, "appId", appId); + putIfPresent(payload, "title", title); + putIfPresent(payload, "store", store); + putIfPresent(payload, "source", source); + return payload; + } + + private String firstUriValue(Uri uri, String[] keys) { + if (uri == null) { + return null; + } + + for (String key : keys) { + String value = queryParameter(uri, key); + if (value != null) { + return value; + } + } + return null; + } + + private String queryParameter(Uri uri, String key) { + try { + return trimToNull(uri.getQueryParameter(key)); + } catch (UnsupportedOperationException ignored) { + return null; + } + } + + private String firstNumericUriPart(Uri uri) { + if (uri == null) { + return null; + } + + String host = normalizeAppId(uri.getHost()); + if (host != null) { + return host; + } + + List segments = uri.getPathSegments(); + for (String segment : segments) { + String normalized = normalizeAppId(segment); + if (normalized != null) { + return normalized; + } + } + return null; + } + + private String firstExtra(Bundle extras, String[] keys) { + if (extras == null) { + return null; + } + + for (String key : keys) { + if (!extras.containsKey(key)) { + continue; + } + String value = valueToString(extras.get(key)); + if (value != null) { + return value; + } + } + return null; + } + + private String valueToString(Object value) { + if (value instanceof String || value instanceof Number || value instanceof Boolean) { + return trimToNull(String.valueOf(value)); + } + return null; + } + + private String firstNonEmpty(String left, String right) { + return left != null ? left : right; + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private String normalizeAppId(String value) { + String trimmed = trimToNull(value); + if (trimmed == null || !trimmed.matches("^[0-9]+$")) { + return null; + } + try { + return Long.parseLong(trimmed) > 0L ? trimmed : null; + } catch (NumberFormatException ignored) { + return null; + } + } + + private void putIfPresent(JSObject payload, String key, String value) { + if (value != null) { + payload.put(key, value); + } + } + + private void applyImmersiveFullscreen(boolean enabled) { + Activity activity = getActivity(); + if (activity == null) { + return; + } + + immersiveFullscreenRequested = enabled; + applyImmersiveFullscreen(activity, enabled); + } + + private interface NativeTouchStateListener { + void onState( + boolean connected, + int buttons, + float leftTrigger, + float rightTrigger, + float leftStickX, + float leftStickY, + float rightStickX, + float rightStickY, + long timestampMs + ); + } + + private static final class NativeTouchControllerView extends View { + private static final int GAMEPAD_DPAD_UP = 0x0001; + private static final int GAMEPAD_DPAD_DOWN = 0x0002; + private static final int GAMEPAD_DPAD_LEFT = 0x0004; + private static final int GAMEPAD_DPAD_RIGHT = 0x0008; + private static final int GAMEPAD_START = 0x0010; + private static final int GAMEPAD_BACK = 0x0020; + private static final int GAMEPAD_LB = 0x0100; + private static final int GAMEPAD_RB = 0x0200; + private static final int GAMEPAD_A = 0x1000; + private static final int GAMEPAD_B = 0x2000; + private static final int GAMEPAD_X = 0x4000; + private static final int GAMEPAD_Y = 0x8000; + private static final int KIND_BUTTON = 1; + private static final int KIND_LEFT_TRIGGER = 2; + private static final int KIND_RIGHT_TRIGGER = 3; + private static final int KIND_LEFT_STICK = 4; + private static final int KIND_RIGHT_STICK = 5; + private static final long MIN_EMIT_INTERVAL_MS = 33L; + + private final NativeTouchStateListener listener; + private final Paint fillPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint strokePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint textPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint thumbPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final List regions = new ArrayList<>(); + private final RectF leftStickRect = new RectF(); + private final RectF rightStickRect = new RectF(); + private float size = 1f; + private float opacity = 1f; + private String placement = "default"; + private int buttons = 0; + private float leftTrigger = 0f; + private float rightTrigger = 0f; + private float leftStickX = 0f; + private float leftStickY = 0f; + private float rightStickX = 0f; + private float rightStickY = 0f; + private int lastButtons = -1; + private float lastLeftTrigger = -1f; + private float lastRightTrigger = -1f; + private float lastLeftStickX = -2f; + private float lastLeftStickY = -2f; + private float lastRightStickX = -2f; + private float lastRightStickY = -2f; + private boolean connected = false; + private long lastEmitTimestampMs = 0L; + + NativeTouchControllerView(Context context, NativeTouchStateListener listener) { + super(context); + this.listener = listener; + setWillNotDraw(false); + setBackgroundColor(Color.TRANSPARENT); + setFocusable(false); + setClickable(false); + setLayerType(View.LAYER_TYPE_SOFTWARE, null); + + fillPaint.setStyle(Paint.Style.FILL); + strokePaint.setStyle(Paint.Style.STROKE); + strokePaint.setStrokeWidth(dp(1.5f)); + textPaint.setColor(Color.WHITE); + textPaint.setTextAlign(Paint.Align.CENTER); + textPaint.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD)); + thumbPaint.setStyle(Paint.Style.FILL); + } + + void configure(float size, float opacity, String placement) { + this.size = clampFloat(size, 0.72f, 1.35f); + this.opacity = clampFloat(opacity, 0.25f, 1f); + this.placement = placement != null ? placement : "default"; + layoutControls(getWidth(), getHeight()); + invalidate(); + } + + void disconnect() { + resetState(); + emitState(true, System.currentTimeMillis(), false); + } + + @Override + protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { + super.onSizeChanged(width, height, oldWidth, oldHeight); + layoutControls(width, height); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + int alpha = Math.round(255f * Math.max(0.92f, opacity)); + fillPaint.setColor(Color.argb(alpha, 13, 17, 21)); + strokePaint.setColor(Color.argb(128, 255, 255, 255)); + thumbPaint.setColor(Color.argb(235, 214, 222, 230)); + textPaint.setTextSize(dp(13f) * size); + + for (TouchRegion region : regions) { + float radius = region.kind == KIND_LEFT_STICK || region.kind == KIND_RIGHT_STICK + ? region.rect.width() / 2f + : dp(10f) * size; + canvas.drawRoundRect(region.rect, radius, radius, fillPaint); + canvas.drawRoundRect(region.rect, radius, radius, strokePaint); + if (region.label != null) { + Paint.FontMetrics metrics = textPaint.getFontMetrics(); + float baseline = region.rect.centerY() - (metrics.ascent + metrics.descent) / 2f; + canvas.drawText(region.label, region.rect.centerX(), baseline, textPaint); + } + } + + drawStickThumb(canvas, leftStickRect, leftStickX, leftStickY); + drawStickThumb(canvas, rightStickRect, rightStickX, rightStickY); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (event == null || regions.isEmpty()) { + return false; + } + + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_DOWN && hitTest(event.getX(), event.getY()) == null) { + return false; + } + + long timestampMs = event.getEventTime(); + if (action == MotionEvent.ACTION_CANCEL) { + resetState(); + emitState(true, timestampMs, false); + invalidate(); + return true; + } + + resetState(); + int actionIndex = event.getActionIndex(); + for (int i = 0; i < event.getPointerCount(); i++) { + if ((action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP) && i == actionIndex) { + continue; + } + applyPointer(event.getX(i), event.getY(i)); + } + + boolean active = isActive(); + boolean wasConnected = connected; + connected = active || wasConnected; + boolean force = action == MotionEvent.ACTION_DOWN + || action == MotionEvent.ACTION_POINTER_DOWN + || action == MotionEvent.ACTION_UP + || action == MotionEvent.ACTION_POINTER_UP; + emitState(force, timestampMs, connected); + if (!active && (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP)) { + connected = false; + } + invalidate(); + return true; + } + + private void layoutControls(int width, int height) { + regions.clear(); + if (width <= 0 || height <= 0) { + return; + } + + float margin = dp(24f) * size; + float bottom = height - dp("lower".equals(placement) ? 24f : 34f) * size; + float stick = dp("compact".equals(placement) ? 92f : 108f) * size; + float button = dp("compact".equals(placement) ? 48f : 56f) * size; + float gap = dp(10f) * size; + float shoulderWidth = dp(72f) * size; + float shoulderHeight = dp(42f) * size; + + leftStickRect.set(margin, bottom - stick, margin + stick, bottom); + rightStickRect.set(width - margin - stick, bottom - stick, width - margin, bottom); + regions.add(new TouchRegion(leftStickRect, KIND_LEFT_STICK, 0, null)); + regions.add(new TouchRegion(rightStickRect, KIND_RIGHT_STICK, 0, null)); + + float dpadLeft = leftStickRect.right + gap; + float dpadTop = leftStickRect.top + (stick - button * 2f - gap) / 2f; + addButton(dpadLeft + button + gap, dpadTop, button, button, GAMEPAD_DPAD_UP, "U"); + addButton(dpadLeft, dpadTop + button + gap, button, button, GAMEPAD_DPAD_LEFT, "L"); + addButton(dpadLeft + (button + gap) * 2f, dpadTop + button + gap, button, button, GAMEPAD_DPAD_RIGHT, "R"); + addButton(dpadLeft + button + gap, dpadTop + (button + gap) * 2f, button, button, GAMEPAD_DPAD_DOWN, "D"); + + float faceLeft = rightStickRect.left - gap - button * 3f - gap * 2f; + float faceTop = rightStickRect.top + (stick - button * 2f - gap) / 2f; + addButton(faceLeft + button + gap, faceTop, button, button, GAMEPAD_Y, "Y"); + addButton(faceLeft, faceTop + button + gap, button, button, GAMEPAD_X, "X"); + addButton(faceLeft + (button + gap) * 2f, faceTop + button + gap, button, button, GAMEPAD_B, "B"); + addButton(faceLeft + button + gap, faceTop + (button + gap) * 2f, button, button, GAMEPAD_A, "A"); + + float centerY = bottom - stick - dp(58f) * size; + float centerButtonWidth = dp(74f) * size; + float centerLeft = width / 2f - centerButtonWidth - gap / 2f; + addButton(centerLeft, centerY, centerButtonWidth, shoulderHeight, GAMEPAD_BACK, "View"); + addButton(centerLeft + centerButtonWidth + gap, centerY, centerButtonWidth, shoulderHeight, GAMEPAD_START, "Menu"); + + float shoulderTop = dp(18f) * size; + addButton(margin, shoulderTop, shoulderWidth, shoulderHeight, GAMEPAD_LB, "LB"); + addTrigger(margin + shoulderWidth + gap, shoulderTop, shoulderWidth, shoulderHeight, true, "LT"); + addTrigger(width - margin - shoulderWidth * 2f - gap, shoulderTop, shoulderWidth, shoulderHeight, false, "RT"); + addButton(width - margin - shoulderWidth, shoulderTop, shoulderWidth, shoulderHeight, GAMEPAD_RB, "RB"); + } + + private void addButton(float left, float top, float width, float height, int buttonMask, String label) { + regions.add(new TouchRegion(new RectF(left, top, left + width, top + height), KIND_BUTTON, buttonMask, label)); + } + + private void addTrigger(float left, float top, float width, float height, boolean leftTrigger, String label) { + regions.add(new TouchRegion( + new RectF(left, top, left + width, top + height), + leftTrigger ? KIND_LEFT_TRIGGER : KIND_RIGHT_TRIGGER, + 0, + label + )); + } + + private void drawStickThumb(Canvas canvas, RectF stickRect, float x, float y) { + if (stickRect.isEmpty()) { + return; + } + float radius = stickRect.width() * 0.16f; + float travel = stickRect.width() * 0.28f; + canvas.drawCircle(stickRect.centerX() + x * travel, stickRect.centerY() + y * travel, radius, thumbPaint); + } + + private void applyPointer(float x, float y) { + TouchRegion region = hitTest(x, y); + if (region == null) { + return; + } + + switch (region.kind) { + case KIND_BUTTON: + buttons |= region.buttonMask; + break; + case KIND_LEFT_TRIGGER: + leftTrigger = 1f; + break; + case KIND_RIGHT_TRIGGER: + rightTrigger = 1f; + break; + case KIND_LEFT_STICK: + float[] left = stickValue(region.rect, x, y); + leftStickX = left[0]; + leftStickY = left[1]; + break; + case KIND_RIGHT_STICK: + float[] right = stickValue(region.rect, x, y); + rightStickX = right[0]; + rightStickY = right[1]; + break; + default: + break; + } + } + + private TouchRegion hitTest(float x, float y) { + for (TouchRegion region : regions) { + if (region.rect.contains(x, y)) { + return region; + } + } + return null; + } + + private float[] stickValue(RectF rect, float x, float y) { + float rawX = (x - rect.centerX()) / Math.max(1f, rect.width() / 2f); + float rawY = (y - rect.centerY()) / Math.max(1f, rect.height() / 2f); + float magnitude = (float) Math.sqrt(rawX * rawX + rawY * rawY); + float scale = magnitude > 1f ? 1f / magnitude : 1f; + return new float[] { + quantize(rawX * scale), + quantize(rawY * scale) + }; + } + + private float quantize(float value) { + return Math.round(clampFloat(value, -1f, 1f) * 100f) / 100f; + } + + private void resetState() { + buttons = 0; + leftTrigger = 0f; + rightTrigger = 0f; + leftStickX = 0f; + leftStickY = 0f; + rightStickX = 0f; + rightStickY = 0f; + } + + private boolean isActive() { + return buttons != 0 + || leftTrigger > 0f + || rightTrigger > 0f + || leftStickX != 0f + || leftStickY != 0f + || rightStickX != 0f + || rightStickY != 0f; + } + + private void emitState(boolean force, long timestampMs, boolean connectedValue) { + boolean changed = force + || buttons != lastButtons + || leftTrigger != lastLeftTrigger + || rightTrigger != lastRightTrigger + || leftStickX != lastLeftStickX + || leftStickY != lastLeftStickY + || rightStickX != lastRightStickX + || rightStickY != lastRightStickY; + if (!changed) { + return; + } + if (!force && timestampMs - lastEmitTimestampMs < MIN_EMIT_INTERVAL_MS) { + return; + } + + lastEmitTimestampMs = timestampMs; + lastButtons = buttons; + lastLeftTrigger = leftTrigger; + lastRightTrigger = rightTrigger; + lastLeftStickX = leftStickX; + lastLeftStickY = leftStickY; + lastRightStickX = rightStickX; + lastRightStickY = rightStickY; + listener.onState( + connectedValue, + buttons, + leftTrigger, + rightTrigger, + leftStickX, + leftStickY, + rightStickX, + rightStickY, + timestampMs + ); + } + + private float dp(float value) { + return value * getResources().getDisplayMetrics().density; + } + + private static final class TouchRegion { + final RectF rect; + final int kind; + final int buttonMask; + final String label; + + TouchRegion(RectF rect, int kind, int buttonMask, String label) { + this.rect = new RectF(rect); + this.kind = kind; + this.buttonMask = buttonMask; + this.label = label; + } + } + } + + public static boolean isImmersiveFullscreenRequested() { + return immersiveFullscreenRequested; + } + + public static void applyImmersiveFullscreen(Activity activity, boolean enabled) { + if (activity == null) { + return; + } + + Window window = activity.getWindow(); + View decorView = window.getDecorView(); + WindowInsetsControllerCompat controller = WindowCompat.getInsetsController(window, decorView); + WindowManager.LayoutParams attributes = window.getAttributes(); + + decorView.setBackgroundColor(Color.BLACK); + window.setStatusBarColor(Color.BLACK); + window.setNavigationBarColor(Color.BLACK); + + WindowCompat.setDecorFitsSystemWindows(window, !enabled); + if (enabled) { + if (!previousRequestedOrientationCaptured) { + previousRequestedOrientation = activity.getRequestedOrientation(); + previousRequestedOrientationCaptured = true; + } + activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; + window.setAttributes(attributes); + } + window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + controller.setSystemBarsBehavior( + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + ); + controller.hide(WindowInsetsCompat.Type.statusBars() | WindowInsetsCompat.Type.navigationBars()); + decorView.setSystemUiVisibility( + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + | View.SYSTEM_UI_FLAG_FULLSCREEN + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE + ); + return; + } + + if (previousRequestedOrientationCaptured) { + activity.setRequestedOrientation(previousRequestedOrientation); + previousRequestedOrientationCaptured = false; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT; + window.setAttributes(attributes); + } + window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + controller.show(WindowInsetsCompat.Type.statusBars() | WindowInsetsCompat.Type.navigationBars()); + decorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE); + } +} diff --git a/opennow-stable/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/opennow-stable/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 00000000..c7bd21db --- /dev/null +++ b/opennow-stable/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/opennow-stable/android/app/src/main/res/drawable/ic_launcher_background.xml b/opennow-stable/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..d5fccc53 --- /dev/null +++ b/opennow-stable/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/opennow-stable/android/app/src/main/res/layout/activity_main.xml b/opennow-stable/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..b4050a2e --- /dev/null +++ b/opennow-stable/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/opennow-stable/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/opennow-stable/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..036d09bc --- /dev/null +++ b/opennow-stable/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/opennow-stable/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/opennow-stable/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..036d09bc --- /dev/null +++ b/opennow-stable/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/opennow-stable/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/opennow-stable/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..5f725014 Binary files /dev/null and b/opennow-stable/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/opennow-stable/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/opennow-stable/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..bf9ca94a Binary files /dev/null and b/opennow-stable/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/opennow-stable/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/opennow-stable/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..5f725014 Binary files /dev/null and b/opennow-stable/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/opennow-stable/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/opennow-stable/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..bc2ad4f2 Binary files /dev/null and b/opennow-stable/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/opennow-stable/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/opennow-stable/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..9ca1fa7b Binary files /dev/null and b/opennow-stable/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/opennow-stable/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/opennow-stable/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..bc2ad4f2 Binary files /dev/null and b/opennow-stable/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/opennow-stable/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/opennow-stable/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..22aadfe3 Binary files /dev/null and b/opennow-stable/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/opennow-stable/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/opennow-stable/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..1fe8ed0e Binary files /dev/null and b/opennow-stable/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/opennow-stable/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/opennow-stable/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..22aadfe3 Binary files /dev/null and b/opennow-stable/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/opennow-stable/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/opennow-stable/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..434bb709 Binary files /dev/null and b/opennow-stable/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/opennow-stable/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/opennow-stable/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..431271dd Binary files /dev/null and b/opennow-stable/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/opennow-stable/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/opennow-stable/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..434bb709 Binary files /dev/null and b/opennow-stable/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/opennow-stable/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/opennow-stable/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..cc29914b Binary files /dev/null and b/opennow-stable/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/opennow-stable/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/opennow-stable/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..cdc8fd2f Binary files /dev/null and b/opennow-stable/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/opennow-stable/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/opennow-stable/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..cc29914b Binary files /dev/null and b/opennow-stable/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/opennow-stable/android/app/src/main/res/values/colors.xml b/opennow-stable/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..c6d8f1d5 --- /dev/null +++ b/opennow-stable/android/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #0F172A + #020617 + #38BDF8 + diff --git a/opennow-stable/android/app/src/main/res/values/ic_launcher_background.xml b/opennow-stable/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 00000000..d4fa00ad --- /dev/null +++ b/opennow-stable/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #020617 + diff --git a/opennow-stable/android/app/src/main/res/values/strings.xml b/opennow-stable/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..1794165e --- /dev/null +++ b/opennow-stable/android/app/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + OpenNOW + OpenNOW + com.opencloudgaming.opennow + diff --git a/opennow-stable/android/app/src/main/res/values/styles.xml b/opennow-stable/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..04ed4017 --- /dev/null +++ b/opennow-stable/android/app/src/main/res/values/styles.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + diff --git a/opennow-stable/android/app/src/main/res/xml/file_paths.xml b/opennow-stable/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..bd0c4d80 --- /dev/null +++ b/opennow-stable/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/opennow-stable/android/build.gradle b/opennow-stable/android/build.gradle new file mode 100644 index 00000000..f8f0e43b --- /dev/null +++ b/opennow-stable/android/build.gradle @@ -0,0 +1,29 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.13.0' + classpath 'com.google.gms:google-services:4.4.4' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +apply from: "variables.gradle" + +allprojects { + repositories { + google() + mavenCentral() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/opennow-stable/android/capacitor.settings.gradle b/opennow-stable/android/capacitor.settings.gradle new file mode 100644 index 00000000..2bdf7637 --- /dev/null +++ b/opennow-stable/android/capacitor.settings.gradle @@ -0,0 +1,18 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') + +include ':capacitor-app' +project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') + +include ':capacitor-device' +project(':capacitor-device').projectDir = new File('../node_modules/@capacitor/device/android') + +include ':capacitor-filesystem' +project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android') + +include ':capacitor-preferences' +project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android') + +include ':capacitor-status-bar' +project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android') diff --git a/opennow-stable/android/gradle.properties b/opennow-stable/android/gradle.properties new file mode 100644 index 00000000..4ee28e43 --- /dev/null +++ b/opennow-stable/android/gradle.properties @@ -0,0 +1,23 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +androidVersionCode=30021 \ No newline at end of file diff --git a/opennow-stable/android/gradle/wrapper/gradle-wrapper.jar b/opennow-stable/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..1b33c55b Binary files /dev/null and b/opennow-stable/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/opennow-stable/android/gradle/wrapper/gradle-wrapper.properties b/opennow-stable/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..7705927e --- /dev/null +++ b/opennow-stable/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/opennow-stable/android/gradlew b/opennow-stable/android/gradlew new file mode 100755 index 00000000..23d15a93 --- /dev/null +++ b/opennow-stable/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/opennow-stable/android/gradlew.bat b/opennow-stable/android/gradlew.bat new file mode 100644 index 00000000..96fefbb8 --- /dev/null +++ b/opennow-stable/android/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/opennow-stable/android/settings.gradle b/opennow-stable/android/settings.gradle new file mode 100644 index 00000000..3b4431d7 --- /dev/null +++ b/opennow-stable/android/settings.gradle @@ -0,0 +1,5 @@ +include ':app' +include ':capacitor-cordova-android-plugins' +project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') + +apply from: 'capacitor.settings.gradle' \ No newline at end of file diff --git a/opennow-stable/android/variables.gradle b/opennow-stable/android/variables.gradle new file mode 100644 index 00000000..ee4ba41c --- /dev/null +++ b/opennow-stable/android/variables.gradle @@ -0,0 +1,16 @@ +ext { + minSdkVersion = 24 + compileSdkVersion = 36 + targetSdkVersion = 36 + androidxActivityVersion = '1.11.0' + androidxAppCompatVersion = '1.7.1' + androidxCoordinatorLayoutVersion = '1.3.0' + androidxCoreVersion = '1.17.0' + androidxFragmentVersion = '1.8.9' + coreSplashScreenVersion = '1.2.0' + androidxWebkitVersion = '1.14.0' + junitVersion = '4.13.2' + androidxJunitVersion = '1.3.0' + androidxEspressoCoreVersion = '3.7.0' + cordovaAndroidVersion = '14.0.1' +} \ No newline at end of file diff --git a/opennow-stable/capacitor.config.ts b/opennow-stable/capacitor.config.ts new file mode 100644 index 00000000..7561ff2a --- /dev/null +++ b/opennow-stable/capacitor.config.ts @@ -0,0 +1,12 @@ +import type { CapacitorConfig } from "@capacitor/cli"; + +const config: CapacitorConfig = { + appId: "com.opencloudgaming.opennow", + appName: "OpenNOW", + webDir: "dist", + server: { + androidScheme: "https", + }, +}; + +export default config; diff --git a/opennow-stable/package-lock.json b/opennow-stable/package-lock.json index 12956d52..fa8e0074 100644 --- a/opennow-stable/package-lock.json +++ b/opennow-stable/package-lock.json @@ -1,13 +1,19 @@ { "name": "opennow-stable", - "version": "0.3.2", + "version": "0.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opennow-stable", - "version": "0.3.2", - "dependencies": { + "version": "0.3.3", + "dependencies": { + "@capacitor/app": "^8.1.0", + "@capacitor/core": "^8.3.0", + "@capacitor/device": "^8.0.2", + "@capacitor/filesystem": "^8.1.2", + "@capacitor/preferences": "^8.0.1", + "@capacitor/status-bar": "^8.0.2", "discord-rpc": "^4.0.1", "electron-updater": "^6.8.3", "lucide-react": "^1.7.0", @@ -16,6 +22,8 @@ "ws": "^8.20.0" }, "devDependencies": { + "@capacitor/android": "^8.3.0", + "@capacitor/cli": "^8.3.0", "@types/discord-rpc": "^4.0.10", "@types/node": "^22.19.17", "@types/react": "^19.2.14", @@ -89,6 +97,16 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { "version": "7.29.1", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", @@ -123,6 +141,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", @@ -331,6 +359,113 @@ "node": ">=6.9.0" } }, + "node_modules/@capacitor/android": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.3.1.tgz", + "integrity": "sha512-hjskIG8YcBEh3X4yaTXvE9gcqpdcxunTgFruSKnuPxtMxAUzEK4Oq25x0Z1g3cz+MQPc+lRG09R7Ovc+ydKsNw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^8.3.0" + } + }, + "node_modules/@capacitor/app": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.1.0.tgz", + "integrity": "sha512-MlmttTOWHDedr/G4SrhNRxsXMqY+R75S4MM4eIgzsgCzOYhb/MpCkA5Q3nuOCfL1oHm26xjUzqZ5aupbOwdfYg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/cli": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.3.1.tgz", + "integrity": "sha512-1sPGW4THTDfR6YjXwZ0jM7oAfAtciPOHN00qs/3sNAQx1kKrrEYSfDPwCm1/xlAgi0OeL69SiRfw314Ans+1sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/cli-framework-output": "^2.2.8", + "@ionic/utils-subprocess": "^3.0.1", + "@ionic/utils-terminal": "^2.3.5", + "commander": "^12.1.0", + "debug": "^4.4.0", + "env-paths": "^2.2.0", + "fs-extra": "^11.2.0", + "kleur": "^4.1.5", + "native-run": "^2.0.3", + "open": "^8.4.0", + "plist": "^3.1.0", + "prompts": "^2.4.2", + "rimraf": "^6.0.1", + "semver": "^7.6.3", + "tar": "^7.5.3", + "tslib": "^2.8.1", + "xml2js": "^0.6.2" + }, + "bin": { + "cap": "bin/capacitor", + "capacitor": "bin/capacitor" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@capacitor/core": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.1.tgz", + "integrity": "sha512-UF8ItlHguU1Z6GXfPTeT2gakf+ctNI8pAS1kwSBQlsJMlfD4OPoto/SmKnOxKCQvnF4WRcdWeg6C0zREUNaAQg==", + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@capacitor/device": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@capacitor/device/-/device-8.0.2.tgz", + "integrity": "sha512-fIqSXnG0s6bz5A/0xFgSXDkbU+Xl65ti80LhucNvLI4kGhJzcNn6SwWVwpXN9SJTOFWXblXknHNppheP8X1frQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/filesystem": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@capacitor/filesystem/-/filesystem-8.1.2.tgz", + "integrity": "sha512-doaaMfGoFR2hWU6aV6u83I+5ZsGyJVq+Gz4r9lMpJzUKMm1eMu0hLnFdV1aXZlU9FlK/RndFrVD8oRZfNOqWgQ==", + "license": "MIT", + "dependencies": { + "@capacitor/synapse": "^1.0.4" + }, + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/preferences": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/preferences/-/preferences-8.0.1.tgz", + "integrity": "sha512-T6no3ebi79XJCk91U3Jp/liJUwgBdvHR+s6vhvPkPxSuch7z3zx5Rv1bdWM6sWruNx+pViuEGqZvbfCdyBvcHQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/status-bar": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.2.tgz", + "integrity": "sha512-WXs8YB8B9eEaPZz+bcdY6t2nForF1FLoj/JU0Dl9RRgQnddnS98FEEyDooQhaY7wivr000j4+SC1FyeJkrFO7A==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/synapse": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz", + "integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==", + "license": "ISC" + }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -375,9 +510,9 @@ "license": "MIT" }, "node_modules/@electron/asar/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -385,6 +520,16 @@ "concat-map": "0.0.1" } }, + "node_modules/@electron/asar/node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/@electron/asar/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -429,29 +574,6 @@ "node": ">=10" } }, - "node_modules/@electron/fuses/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/fuses/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@electron/get": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", @@ -474,6 +596,51 @@ "global-agent": "^3.0.0" } }, + "node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@electron/get/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@electron/get/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/@electron/notarize": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", @@ -505,29 +672,6 @@ "node": ">=10" } }, - "node_modules/@electron/notarize/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/notarize/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@electron/osx-sign": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", @@ -578,29 +722,6 @@ "url": "https://github.com/sponsors/gjtorikian/" } }, - "node_modules/@electron/osx-sign/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/osx-sign/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@electron/rebuild": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.3.tgz", @@ -629,19 +750,6 @@ "node": ">=22.12.0" } }, - "node_modules/@electron/rebuild/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@electron/universal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", @@ -669,43 +777,15 @@ "license": "MIT" }, "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, - "node_modules/@electron/universal/node_modules/fs-extra": { - "version": "11.3.4", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", - "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/universal/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/@electron/universal/node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -722,16 +802,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@electron/universal/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@electron/windows-sign": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", @@ -753,47 +823,6 @@ "node": ">=14.14" } }, - "node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.3.4", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", - "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/windows-sign/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@epic-web/invariant": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", @@ -1243,6 +1272,154 @@ "node": ">=18" } }, + "node_modules/@ionic/cli-framework-output": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", + "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-array": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.6.tgz", + "integrity": "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", + "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@ionic/utils-object": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.6.tgz", + "integrity": "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-process": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.12.tgz", + "integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-object": "2.1.6", + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "tree-kill": "^1.2.2", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.7.tgz", + "integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-subprocess": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz", + "integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-array": "2.1.6", + "@ionic/utils-fs": "3.1.7", + "@ionic/utils-process": "2.1.12", + "@ionic/utils-stream": "3.1.7", + "@ionic/utils-terminal": "2.3.5", + "cross-spawn": "^7.0.3", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", + "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1464,29 +1641,6 @@ "node": ">=10" } }, - "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@malept/flatpak-bundler/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@npmcli/agent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", @@ -1524,19 +1678,6 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/@npmcli/fs/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2082,9 +2223,9 @@ "license": "MIT" }, "node_modules/@types/fs-extra": { - "version": "9.0.13", - "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", - "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", + "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2168,6 +2309,13 @@ "@types/node": "*" } }, + "node_modules/@types/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/verror": { "version": "1.10.11", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", @@ -2419,6 +2567,16 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -2429,80 +2587,55 @@ "semver": "bin/semver.js" } }, - "node_modules/app-builder-lib/node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/app-builder-lib/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, "engines": { - "node": ">=12" + "node": ">= 4.0.0" } }, - "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "node_modules/app-builder-lib/node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", "dev": true, "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/jsonfile/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "@types/node": "*" } }, - "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/jsonfile/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], "license": "MIT", "engines": { - "node": ">= 4.0.0" + "node": ">=8" } }, - "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, "engines": { - "node": ">= 10.0.0" + "node": ">=12" } }, "node_modules/app-builder-lib/node_modules/isexe": { @@ -2515,16 +2648,6 @@ "node": ">=18" } }, - "node_modules/app-builder-lib/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/app-builder-lib/node_modules/which": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", @@ -2564,7 +2687,6 @@ "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=8" } @@ -2635,9 +2757,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.15", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.15.tgz", - "integrity": "sha512-1nfKCq9wuAZFTkA2ey/3OXXx7GzFjLdkTiFVNwlJ9WqdI706CZRIhEqjuwanjMIja+84jDLa9rcyZDPDiVkASQ==", + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2647,6 +2769,16 @@ "node": ">=6.0.0" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -2688,6 +2820,19 @@ "license": "MIT", "optional": true }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -2831,29 +2976,6 @@ "node": ">=12" } }, - "node_modules/builder-util/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/builder-util/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -2896,9 +3018,9 @@ "license": "MIT" }, "node_modules/cacache/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -2994,9 +3116,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001785", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz", - "integrity": "sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==", + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", "dev": true, "funding": [ { @@ -3108,6 +3230,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3180,13 +3318,13 @@ } }, "node_modules/commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=18" } }, "node_modules/compare-version": { @@ -3368,6 +3506,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -3434,9 +3582,9 @@ "license": "MIT" }, "node_modules/dir-compare/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -3524,29 +3672,6 @@ "node": ">=12" } }, - "node_modules/dmg-builder/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/dmg-builder/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/dmg-license": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", @@ -3642,9 +3767,9 @@ } }, "node_modules/electron": { - "version": "41.1.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-41.1.1.tgz", - "integrity": "sha512-8bgvDhBjli+3Z2YCKgzzoBPh6391pr7Xv2h/tTJG4ETgvPvUxZomObbZLs31mUzYb6VrlcDDd9cyWyNKtPm3tA==", + "version": "41.2.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-41.2.1.tgz", + "integrity": "sha512-teeRThiYGTPKf/2yOW7zZA1bhb91KEQ4yLBPOg7GxpmnkLFLugKgQaAKOrCgdzwsXh/5mFIfmkm+4+wACJKwaA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3714,29 +3839,6 @@ "node": ">=12" } }, - "node_modules/electron-builder/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/electron-builder/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/electron-publish": { "version": "26.8.1", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz", @@ -3754,6 +3856,16 @@ "mime": "^2.5.2" } }, + "node_modules/electron-publish/node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/electron-publish/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -3769,33 +3881,10 @@ "node": ">=12" } }, - "node_modules/electron-publish/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/electron-publish/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/electron-to-chromium": { - "version": "1.5.331", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", - "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "version": "1.5.340", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", + "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==", "dev": true, "license": "ISC" }, @@ -3829,39 +3918,6 @@ "node": ">=12" } }, - "node_modules/electron-updater/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/electron-updater/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/electron-updater/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/electron-vite": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/electron-vite/-/electron-vite-5.0.0.tgz", @@ -3928,6 +3984,26 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/electron/node_modules/@types/node": { "version": "24.12.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", @@ -3945,6 +4021,26 @@ "dev": true, "license": "MIT" }, + "node_modules/elementtree": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", + "integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "sax": "1.1.4" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/elementtree/node_modules/sax": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", + "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", + "dev": true, + "license": "ISC" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -4218,9 +4314,9 @@ "license": "MIT" }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -4288,18 +4384,18 @@ } }, "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">=6 <7 || >=8" + "node": ">=14.14" } }, "node_modules/fs-minipass": { @@ -4423,9 +4519,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", - "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", "dev": true, "license": "MIT", "dependencies": { @@ -4465,9 +4561,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -4494,31 +4590,17 @@ "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", "dev": true, "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "es6-error": "^4.1.1", - "matcher": "^3.0.0", - "roarr": "^2.15.3", - "semver": "^7.3.2", - "serialize-error": "^7.0.1" - }, - "engines": { - "node": ">=10.0" - } - }, - "node_modules/global-agent/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" }, "engines": { - "node": ">=10" + "node": ">=10.0" } }, "node_modules/globalthis": { @@ -4813,6 +4895,16 @@ "dev": true, "license": "ISC" }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -4823,6 +4915,22 @@ "node": ">= 12" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -4856,6 +4964,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isbinaryfile": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", @@ -4988,11 +5109,13 @@ } }, "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -5008,9 +5131,9 @@ } }, "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", "dev": true, "license": "MIT", "engines": { @@ -5081,9 +5204,9 @@ } }, "node_modules/lucide-react": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz", - "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz", + "integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -5419,6 +5542,32 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/native-run": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz", + "integrity": "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-fs": "^3.1.7", + "@ionic/utils-terminal": "^2.3.4", + "bplist-parser": "^0.3.2", + "debug": "^4.3.4", + "elementtree": "^0.1.7", + "ini": "^4.1.1", + "plist": "^3.1.0", + "split2": "^4.2.0", + "through2": "^4.0.2", + "tslib": "^2.6.2", + "yauzl": "^2.10.0" + }, + "bin": { + "native-run": "bin/native-run" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -5442,19 +5591,6 @@ "node": ">=22.12.0" } }, - "node_modules/node-abi/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-addon-api": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", @@ -5472,19 +5608,6 @@ "semver": "^7.3.5" } }, - "node_modules/node-api-version/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -5540,19 +5663,6 @@ "node": ">=18" } }, - "node_modules/node-gyp/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-gyp/node_modules/which": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", @@ -5642,6 +5752,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ora": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", @@ -5814,9 +5942,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { @@ -5930,6 +6058,16 @@ "node": ">= 6" } }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -5977,9 +6115,9 @@ } }, "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", "peer": true, "engines": { @@ -5987,16 +6125,16 @@ } }, "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", "peer": true, "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.4" + "react": "^19.2.5" } }, "node_modules/react-refresh": { @@ -6183,17 +6321,68 @@ } }, "node_modules/rimraf": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", - "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "glob": "^7.1.3" + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" }, "bin": { - "rimraf": "bin.js" + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/rimraf/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/roarr": { @@ -6315,13 +6504,15 @@ "license": "MIT" }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/semver-compare": { @@ -6392,19 +6583,6 @@ "node": ">=10" } }, - "node_modules/simple-update-notifier/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -6413,19 +6591,21 @@ "license": "MIT" }, "node_modules/slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", - "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, "node_modules/smart-buffer": { @@ -6500,6 +6680,16 @@ "source-map": "^0.6.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -6692,27 +6882,28 @@ "node": ">=12" } }, - "node_modules/temp-file/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "node_modules/temp/node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "universalify": "^2.0.0" + "glob": "^7.1.3" }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "bin": { + "rimraf": "bin.js" } }, - "node_modules/temp-file/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 10.0.0" + "dependencies": { + "readable-stream": "3" } }, "node_modules/tiny-async-pool": { @@ -6742,14 +6933,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -6784,6 +6975,16 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/truncate-utf8-bytes": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", @@ -6794,6 +6995,12 @@ "utf8-byte-length": "^1.0.1" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -7313,9 +7520,9 @@ } }, "node_modules/typescript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", - "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -7360,13 +7567,12 @@ } }, "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "license": "MIT", "engines": { - "node": ">= 4.0.0" + "node": ">= 10.0.0" } }, "node_modules/unplugin": { @@ -7384,6 +7590,16 @@ "node": ">=18.12.0" } }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -7456,9 +7672,9 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", "peer": true, @@ -8130,6 +8346,30 @@ } } }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/opennow-stable/package.json b/opennow-stable/package.json index af83814d..288aa4c7 100644 --- a/opennow-stable/package.json +++ b/opennow-stable/package.json @@ -1,6 +1,6 @@ { "name": "opennow-stable", - "version": "0.3.2", + "version": "0.3.3", "description": "Electron-based OpenNOW stable client", "author": { "name": "zortos293", @@ -18,12 +18,21 @@ "dev": "electron-vite dev", "build": "electron-vite build", "preview": "electron-vite preview", + "build:web": "electron-vite build", + "cap:sync:android": "npm run build:web && npx cap sync android", + "cap:open:android": "npx cap open android", "dist": "npm run build && cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder", "dist:signed": "npm run build && electron-builder", "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.json", "test": "tsx --test src/renderer/src/gfn/inputProtocol.test.ts" }, "dependencies": { + "@capacitor/app": "^8.1.0", + "@capacitor/core": "^8.3.0", + "@capacitor/device": "^8.0.2", + "@capacitor/filesystem": "^8.1.2", + "@capacitor/preferences": "^8.0.1", + "@capacitor/status-bar": "^8.0.2", "discord-rpc": "^4.0.1", "electron-updater": "^6.8.3", "lucide-react": "^1.7.0", @@ -32,6 +41,8 @@ "ws": "^8.20.0" }, "devDependencies": { + "@capacitor/android": "^8.3.0", + "@capacitor/cli": "^8.3.0", "@types/discord-rpc": "^4.0.10", "@types/node": "^22.19.17", "@types/react": "^19.2.14", diff --git a/opennow-stable/src/main/gfn/auth.ts b/opennow-stable/src/main/gfn/auth.ts index 03f6331b..a39871e0 100644 --- a/opennow-stable/src/main/gfn/auth.ts +++ b/opennow-stable/src/main/gfn/auth.ts @@ -118,6 +118,45 @@ function parseJwtPayload(token: string): T | null { } } +function avatarInitials(label: string | undefined): string { + const cleaned = (label ?? "User").trim(); + if (!cleaned) { + return "U"; + } + const parts = cleaned.split(/\s+/).filter(Boolean).slice(0, 2); + const initials = parts.map((part) => part[0]?.toUpperCase() ?? "").join(""); + return initials || cleaned[0]?.toUpperCase() || "U"; +} + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); +} + +function createLocalAvatarUrl(seed: string, label: string | undefined): string { + const digest = createHash("sha256").update(seed).digest("hex"); + const hue = Number.parseInt(digest.slice(0, 8), 16) % 360; + const initials = avatarInitials(label); + const escapedInitials = escapeXml(initials); + const svg = `${escapedInitials}`; + return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`; +} + +function resolveAvatarUrl(options: { userId: string; email?: string; picture?: string; displayName?: string }): string | undefined { + if (options.picture) { + return options.picture; + } + const seed = options.email?.trim().toLowerCase() || options.userId; + if (!seed) { + return undefined; + } + return createLocalAvatarUrl(seed, options.displayName ?? options.email); +} + function toExpiresAt(expiresInSeconds: number | undefined, defaultSeconds = 86400): number { return Date.now() + (expiresInSeconds ?? defaultSeconds) * 1000; } @@ -369,12 +408,6 @@ function mergeTokenSnapshot(base: AuthTokens, refreshed: TokenResponse): AuthTok }; } -function gravatarUrl(email: string, size = 80): string { - const normalized = email.trim().toLowerCase(); - const hash = createHash("md5").update(normalized).digest("hex"); - return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=identicon`; -} - async function fetchUserInfo(tokens: AuthTokens): Promise { const jwtToken = tokens.idToken ?? tokens.accessToken; const parsed = parseJwtPayload<{ @@ -389,12 +422,12 @@ async function fetchUserInfo(tokens: AuthTokens): Promise { const emailFromToken = parsed.email; const pictureFromToken = parsed.picture; if (emailFromToken || pictureFromToken) { - const avatar = pictureFromToken ?? (emailFromToken ? gravatarUrl(emailFromToken) : undefined); + const displayName = parsed.preferred_username ?? emailFromToken?.split("@")[0] ?? "User"; return { userId: parsed.sub, - displayName: parsed.preferred_username ?? emailFromToken?.split("@")[0] ?? "User", + displayName, email: emailFromToken, - avatarUrl: avatar, + avatarUrl: resolveAvatarUrl({ userId: parsed.sub, email: emailFromToken, picture: pictureFromToken, displayName }), membershipTier: parsed.gfn_tier ?? "FREE", }; } @@ -421,13 +454,13 @@ async function fetchUserInfo(tokens: AuthTokens): Promise { }; const email = payload.email; - const avatar = payload.picture ?? (email ? gravatarUrl(email) : undefined); + const displayName = payload.preferred_username ?? email?.split("@")[0] ?? "User"; return { userId: payload.sub, - displayName: payload.preferred_username ?? email?.split("@")[0] ?? "User", + displayName, email, - avatarUrl: avatar, + avatarUrl: resolveAvatarUrl({ userId: payload.sub, email, picture: payload.picture, displayName }), membershipTier: "FREE", }; } diff --git a/opennow-stable/src/main/gfn/games.ts b/opennow-stable/src/main/gfn/games.ts index 9223f671..8fe62187 100644 --- a/opennow-stable/src/main/gfn/games.ts +++ b/opennow-stable/src/main/gfn/games.ts @@ -157,7 +157,7 @@ interface CatalogDefinitions { function optimizeImage(url: string): string { if (url.includes("img.nvidiagrid.net")) { - return `${url};f=webp;w=272`; + return `${url};f=webp;w=544`; } return url; } @@ -166,7 +166,7 @@ function isNumericId(value: string | undefined): value is string { if (!value) { return false; } - return /^\d+$/.test(value); + return /^\d+$/.test(value) && Number.parseInt(value, 10) > 0; } function randomHuId(): string { diff --git a/opennow-stable/src/main/settings.ts b/opennow-stable/src/main/settings.ts index efc27c32..7ed1e2be 100644 --- a/opennow-stable/src/main/settings.ts +++ b/opennow-stable/src/main/settings.ts @@ -1,8 +1,9 @@ import { app } from "electron"; import { join } from "node:path"; import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; -import type { VideoCodec, ColorQuality, VideoAccelerationPreference, MicrophoneMode, GameLanguage, AspectRatio, KeyboardLayout } from "@shared/gfn"; -import { DEFAULT_KEYBOARD_LAYOUT, getDefaultStreamPreferences, normalizeStreamPreferences } from "@shared/gfn"; +import type { AndroidTouchSettings, VideoCodec, ColorQuality, VideoAccelerationPreference, MicrophoneMode, GameLanguage, AspectRatio, KeyboardLayout } from "../shared/gfn"; +import { normalizeStreamPreferences } from "../shared/gfn"; +import { DEFAULT_SETTINGS as SHARED_DEFAULT_SETTINGS, normalizeSettings } from "../shared/settings"; export interface Settings { /** Video resolution (e.g., "1920x1080") */ @@ -74,6 +75,8 @@ export interface Settings { favoriteGameIds: string[]; /** Enable the live elapsed session counter */ sessionCounterEnabled: boolean; + /** Hide warnings before launching free-tier sessions */ + hideFreeTierSessionWarnings: boolean; /** Window width */ windowWidth: number; /** Window height */ @@ -90,6 +93,8 @@ export interface Settings { discordRichPresence: boolean; /** Automatically check GitHub Releases for app updates in the background */ autoCheckForUpdates: boolean; + /** Android in-stream touch controller and mouse overlay preferences */ + androidTouchControls: AndroidTouchSettings; } const defaultStopShortcut = "Ctrl+Shift+Q"; @@ -97,53 +102,7 @@ const defaultAntiAfkShortcut = "Ctrl+Shift+K"; const defaultMicShortcut = "Ctrl+Shift+M"; const LEGACY_STOP_SHORTCUTS = new Set(["META+SHIFT+Q", "CMD+SHIFT+Q"]); const LEGACY_ANTI_AFK_SHORTCUTS = new Set(["META+SHIFT+F10", "CMD+SHIFT+F10", "CTRL+SHIFT+F10"]); -const DEFAULT_STREAM_PREFERENCES = getDefaultStreamPreferences(); - -const DEFAULT_SETTINGS: Settings = { - resolution: "1920x1080", - aspectRatio: "16:9", - posterSizeScale: 1, - fps: 60, - maxBitrateMbps: 75, - codec: DEFAULT_STREAM_PREFERENCES.codec, - decoderPreference: "auto", - encoderPreference: "auto", - colorQuality: DEFAULT_STREAM_PREFERENCES.colorQuality, - region: "", - clipboardPaste: false, - mouseSensitivity: 1, - mouseAcceleration: 1, - shortcutToggleStats: "F3", - shortcutTogglePointerLock: "F8", - shortcutToggleFullscreen: "F10", - shortcutStopStream: defaultStopShortcut, - shortcutToggleAntiAfk: defaultAntiAfkShortcut, - shortcutToggleMicrophone: defaultMicShortcut, - shortcutScreenshot: "F11", - shortcutToggleRecording: "F12", - microphoneMode: "disabled", - microphoneDeviceId: "", - hideStreamButtons: false, - showAntiAfkIndicator: true, - showStatsOnLaunch: false, - controllerMode: false, - controllerUiSounds: false, - controllerBackgroundAnimations: false, - autoLoadControllerLibrary: false, - autoFullScreen: false, - favoriteGameIds: [], - sessionCounterEnabled: false, - sessionClockShowEveryMinutes: 60, - sessionClockShowDurationSeconds: 30, - windowWidth: 1400, - windowHeight: 900, - keyboardLayout: DEFAULT_KEYBOARD_LAYOUT, - gameLanguage: "en_US", - enableL4S: false, - enableCloudGsync: false, - discordRichPresence: false, - autoCheckForUpdates: true, -}; +const DEFAULT_SETTINGS: Settings = { ...SHARED_DEFAULT_SETTINGS }; export class SettingsManager { private settings: Settings; @@ -169,10 +128,7 @@ export class SettingsManager { const parsed = JSON.parse(content) as Partial; // Merge with defaults to ensure all fields exist - const merged: Settings = { - ...DEFAULT_SETTINGS, - ...parsed, - }; + const merged: Settings = normalizeSettings(parsed); let migrated = this.migrateLegacyShortcutDefaults(merged); migrated = this.enforceCompatibility(merged) || migrated; diff --git a/opennow-stable/src/preload/index.ts b/opennow-stable/src/preload/index.ts index b633d739..ca33a24f 100644 --- a/opennow-stable/src/preload/index.ts +++ b/opennow-stable/src/preload/index.ts @@ -123,6 +123,12 @@ const api: OpenNowApi = { toggleFullscreen: () => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_FULLSCREEN), setFullscreen: (v: boolean) => ipcRenderer.invoke(IPC_CHANNELS.SET_FULLSCREEN, v), togglePointerLock: () => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_POINTER_LOCK), + setNativePointerCapture: async () => undefined, + onNativeMouseMove: () => () => undefined, + onNativeMouseButton: () => () => undefined, + onNativeMouseWheel: () => () => undefined, + consumeLaunchIntent: async () => null, + onLaunchIntent: () => () => undefined, getSettings: () => ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_GET), setSetting: (key: K, value: Settings[K]) => ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_SET, key, value), diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 0fe45a2e..d847800b 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -1,14 +1,18 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties, JSX } from "react"; import { createPortal } from "react-dom"; +import { App as CapacitorApp } from "@capacitor/app"; import type { ActiveSessionInfo, + AndroidPerformanceInfo, + AndroidLaunchIntent, AuthSession, AuthUser, CatalogBrowseResult, CatalogFilterGroup, CatalogSortOption, + ColorQuality, ExistingSessionStrategy, GameInfo, GameVariant, @@ -26,6 +30,7 @@ import type { PrintedWasteQueueData, PrintedWasteServerMapping, } from "@shared/gfn"; +import { LEGACY_ANDROID_TOUCH_SETTINGS_KEY, normalizeAndroidTouchSettings } from "@shared/settings"; import { DEFAULT_KEYBOARD_LAYOUT, getDefaultStreamPreferences, @@ -48,6 +53,7 @@ import { useControllerNavigation } from "./controllerNavigation"; import { useElapsedSeconds } from "./utils/useElapsedSeconds"; import { usePlaytime } from "./utils/usePlaytime"; import { createStreamDiagnosticsStore } from "./utils/streamDiagnosticsStore"; +import { openNow, platformCapabilities, platform } from "./platform"; import { loadStoredCodecResults, saveStoredCodecResults, testCodecSupport, type CodecTestResult } from "./lib/codecDiagnostics"; // UI Components @@ -62,15 +68,208 @@ import { ControllerStreamLoading } from "./components/ControllerStreamLoading"; import type { QueueAdPlaybackEvent, QueueAdPreviewHandle } from "./components/QueueAdPreview"; import { StreamView } from "./components/StreamView"; import { QueueServerSelectModal } from "./components/QueueServerSelectModal"; +import { LaunchStorePickerModal, hasMultipleLaunchStoreOptions } from "./components/LaunchStorePickerModal"; const codecOptions: VideoCodec[] = [...USER_FACING_VIDEO_CODEC_OPTIONS]; const DEFAULT_STREAM_PREFERENCES = getDefaultStreamPreferences(); -const allResolutionOptions = ["1280x720", "1280x800", "1440x900", "1680x1050", "1920x1080", "1920x1200", "2560x1080", "2560x1440", "2560x1600", "3440x1440", "3840x2160", "3840x2400"]; +const allResolutionOptions = ["1280x720", "1680x720", "1280x800", "1440x900", "1680x1050", "1920x1080", "1920x1200", "2560x1080", "2560x1440", "2560x1600", "3440x1440", "3840x2160", "3840x2400"]; const fpsOptions = [30, 60, 120, 144, 240]; const aspectRatioOptions = ["16:9", "16:10", "21:9", "32:9"] as const; +type EffectiveStreamPreferences = { + resolution: string; + fps: number; + maxBitrateMbps: number; + codec: VideoCodec; + colorQuality: ColorQuality; + compatibilityReason?: string; +}; + +type PlayGameOptions = { + bypassGuards?: boolean; + streamingBaseUrl?: string; + variantId?: string; +}; + +function readWebGlRendererLabel(): string { + try { + const canvas = document.createElement("canvas"); + const gl = canvas.getContext("webgl2") ?? canvas.getContext("webgl"); + if (!gl) { + return ""; + } + const debugInfo = gl.getExtension("WEBGL_debug_renderer_info"); + if (debugInfo) { + return String(gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) ?? ""); + } + return String(gl.getParameter(gl.RENDERER) ?? ""); + } catch { + return ""; + } +} + +function getAndroidStreamCompatibilityReason(): string | null { + if (!platformCapabilities.isAndroid) { + return null; + } + + const renderer = readWebGlRendererLabel(); + if (/powervr|ge8320|ge83\d{2}|ge8\d{3}/i.test(renderer)) { + return renderer || "PowerVR Android GPU"; + } + const userAgent = navigator.userAgent ?? ""; + const isTvLikeAndroid = + /android tv|google tv|googletv|gtv|smart-?tv|bravia|aft\w*|shield android tv|crkey/i.test(userAgent) || + navigator.maxTouchPoints === 0; + if (isTvLikeAndroid) { + return "Android TV / Google TV WebView"; + } + return null; +} + +function shouldUseLowPowerAndroidTouchControls( + androidCompatibilityReason: string | null, + androidPerformanceInfo: AndroidPerformanceInfo | null, +): boolean { + if (/powervr|ge8320|ge83\d{2}|ge8\d{3}/i.test(androidCompatibilityReason ?? "")) { + return true; + } + if (androidPerformanceInfo?.liteTouchRecommended || androidPerformanceInfo?.lowMemory) { + return true; + } + const totalMemBytes = androidPerformanceInfo?.totalMemBytes; + return typeof totalMemBytes === "number" && totalMemBytes > 0 && totalMemBytes < 3 * 1024 * 1024 * 1024; +} + +function androidGamePrefersMouseInput(game: GameInfo | null): boolean { + if (!platformCapabilities.isAndroid || !game) { + return false; + } + + if (/runescape|old school rune/i.test(game.title)) { + return true; + } + + const controls = game.variants.flatMap((variant) => variant.supportedControls ?? []); + if (controls.length === 0) { + return false; + } + const normalizedControls = controls.map((control) => control.toLowerCase()); + const hasMouseKeyboard = normalizedControls.some((control) => ( + control.includes("mouse") || + control.includes("keyboard") || + control.includes("kbm") + )); + const hasController = normalizedControls.some((control) => ( + control.includes("gamepad") || + control.includes("controller") + )); + return hasMouseKeyboard && !hasController; +} + +function capAndroidCompatibilityResolution(resolution: string): string { + const normalizedResolution = normalizeAndroidStreamResolution(resolution); + const match = /^(\d+)x(\d+)$/.exec(normalizedResolution); + if (!match?.[1] || !match[2]) { + return "1920x1080"; + } + + const width = Number.parseInt(match[1], 10); + const height = Number.parseInt(match[2], 10); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return "1920x1080"; + } + + return width * height > 1920 * 1080 ? "1920x1080" : normalizedResolution; +} + +function normalizeAndroidStreamResolution(resolution: string): string { + const match = /^(\d+)x(\d+)$/.exec(resolution); + if (!match?.[1] || !match[2]) { + return "1920x1080"; + } + + const width = Number.parseInt(match[1], 10); + const height = Number.parseInt(match[2], 10); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return "1920x1080"; + } + + const ratio = width / height; + const isSupportedAspect = + Math.abs(ratio - 16 / 9) < 0.01 || + Math.abs(ratio - 16 / 10) < 0.01 || + Math.abs(ratio - 21 / 9) < 0.025 || + Math.abs(ratio - 32 / 9) < 0.025; + if (isSupportedAspect) { + return resolution; + } + + const pixels = width * height; + if (pixels <= 1280 * 720) { + return "1280x720"; + } + if (pixels <= 1920 * 1080) { + return "1920x1080"; + } + if (pixels <= 2560 * 1440) { + return "2560x1440"; + } + return "3840x2160"; +} + +function getEffectiveStreamPreferences( + settings: Settings, + androidCompatibilityReason: string | null, +): EffectiveStreamPreferences { + const resolution = platformCapabilities.isAndroid + ? normalizeAndroidStreamResolution(settings.resolution) + : settings.resolution; + + if (!androidCompatibilityReason) { + return { + resolution, + fps: settings.fps, + maxBitrateMbps: settings.maxBitrateMbps, + codec: settings.codec, + colorQuality: settings.colorQuality, + }; + } + + return { + resolution: capAndroidCompatibilityResolution(resolution), + fps: Math.min(settings.fps, 60), + maxBitrateMbps: Math.min(settings.maxBitrateMbps, 35), + codec: "H264", + colorQuality: "8bit_420", + compatibilityReason: androidCompatibilityReason, + }; +} + +async function migrateLegacyAndroidTouchSettings(settings: Settings): Promise { + if (!platformCapabilities.isAndroid || typeof window === "undefined") { + return settings; + } + + const raw = window.localStorage.getItem(LEGACY_ANDROID_TOUCH_SETTINGS_KEY); + if (!raw) { + return settings; + } + + try { + const migrated = normalizeAndroidTouchSettings(JSON.parse(raw)); + await openNow.setSetting("androidTouchControls", migrated); + window.localStorage.removeItem(LEGACY_ANDROID_TOUCH_SETTINGS_KEY); + return { ...settings, androidTouchControls: migrated }; + } catch (error) { + console.warn("Failed to migrate Android touch-control settings:", error); + return settings; + } +} + const RESOLUTION_TO_ASPECT_RATIO: Record = { "1280x720": "16:9", + "1680x720": "21:9", "1280x800": "16:10", "1440x900": "16:10", "1680x1050": "16:10", @@ -162,6 +361,8 @@ type LaunchErrorState = { title: string; description: string; codeLabel?: string; + debugDetails?: string[]; + occurredAtIso: string; }; type QueueAdCancelReason = "error" | "other"; type QueueAdErrorInfo = "Ad play timeout" | "Ad video is stuck" | "Error loading url"; @@ -249,7 +450,7 @@ function isSessionInQueue(session: SessionInfo): boolean { function isNumericId(value: string | undefined): value is string { if (!value) return false; - return /^\d+$/.test(value); + return /^\d+$/.test(value) && Number.parseInt(value, 10) > 0; } function parseNumericId(value: string | undefined): number | null { @@ -289,6 +490,48 @@ function findSessionContextForAppId( return null; } +function withPreferredVariant(game: GameInfo, variant: GameVariant | undefined, launchAppId: string): GameInfo { + if (!variant) { + return { ...game, launchAppId: game.launchAppId ?? launchAppId }; + } + + return { + ...game, + launchAppId, + selectedVariantIndex: 0, + variants: [variant, ...game.variants.filter((candidate) => candidate.id !== variant.id)], + }; +} + +function gameFromAndroidLaunchIntent( + intent: AndroidLaunchIntent, + catalog: GameInfo[], + variantByGameId: Record, +): GameInfo | null { + const appId = parseNumericId(intent.appId); + if (appId === null) { + return null; + } + + const catalogMatch = findSessionContextForAppId(catalog, variantByGameId, appId); + if (catalogMatch) { + return withPreferredVariant(catalogMatch.game, catalogMatch.variant, String(appId)); + } + + const store = intent.store?.trim() || "GeForce NOW"; + const title = intent.title?.trim() || `GeForce NOW app ${appId}`; + return { + id: `android-intent:${appId}`, + uuid: String(appId), + launchAppId: String(appId), + title, + availableStores: [store], + searchText: [title, store, intent.source].filter((value): value is string => Boolean(value)).join(" ").toLowerCase(), + selectedVariantIndex: 0, + variants: [{ id: String(appId), store, supportedControls: [] }], + }; +} + function matchesGameSearch(game: GameInfo, query: string): boolean { const normalizedQuery = query.trim().toLowerCase(); if (!normalizedQuery) return true; @@ -383,6 +626,7 @@ function defaultDiagnostics(): StreamDiagnostics { connectionState: "closed", inputReady: false, connectedGamepads: 0, + physicalGamepads: 0, resolution: "", codec: "", isHdr: false, @@ -730,8 +974,49 @@ function extractLaunchErrorCode(error: unknown): number | undefined { return undefined; } +function compactDebugValue(value: unknown, limit = 12000): string | null { + if (value == null) return null; + let text: string; + if (typeof value === "string") { + text = value; + } else { + try { + text = JSON.stringify(value); + } catch { + text = String(value); + } + } + const trimmed = text.trim(); + if (!trimmed) return null; + return trimmed.length > limit ? `${trimmed.slice(0, limit)}...` : trimmed; +} + +function extractLaunchErrorDebugDetails(error: unknown): string[] { + if (!error || typeof error !== "object") return []; + const details: string[] = []; + if ("method" in error && typeof error.method === "string") { + details.push(`Request method: ${error.method}`); + } + if ("url" in error && typeof error.url === "string") { + details.push(`Request URL: ${error.url}`); + } + if ("status" in error && typeof error.status === "number") { + details.push(`HTTP status: ${error.status}`); + } + if ("body" in error) { + const body = compactDebugValue(error.body); + if (body) details.push(`Response body: ${body}`); + } + if ("data" in error) { + const data = compactDebugValue(error.data); + if (data && !details.some((line) => line.endsWith(data))) details.push(`Response data: ${data}`); + } + return details; +} + function toLaunchErrorState(error: unknown, stage: StreamLoadingStatus): LaunchErrorState { const unknownMessage = "The game could not start. Please try again."; + const occurredAtIso = new Date().toISOString(); const titleFromError = error && typeof error === "object" && "title" in error && typeof error.title === "string" @@ -760,6 +1045,24 @@ function toLaunchErrorState(error: unknown, stage: StreamLoadingStatus): LaunchE title: "Duplicate Session Detected", description: "Another session is already running on your account. Close it first or wait for it to timeout, then launch again.", codeLabel: toCodeLabel(code), + debugDetails: extractLaunchErrorDebugDetails(error), + occurredAtIso, + }; + } + + if ( + combined.includes("PATCHING") || + combined.includes("MAINTENANCE") || + combined.includes("GAME_UNDER_MAINTENANCE") || + combined.includes("APP_UNDER_MAINTENANCE") + ) { + return { + stage, + title: "Game Is Patching", + description: "This game is currently patching or under maintenance on GeForce NOW. Try again after the patch finishes.", + codeLabel: toCodeLabel(code), + debugDetails: extractLaunchErrorDebugDetails(error), + occurredAtIso, }; } @@ -768,9 +1071,31 @@ function toLaunchErrorState(error: unknown, stage: StreamLoadingStatus): LaunchE title: titleFromError || "Launch Failed", description: descriptionFromError || messageFromError || statusDescription || unknownMessage, codeLabel: toCodeLabel(code), + debugDetails: extractLaunchErrorDebugDetails(error), + occurredAtIso, }; } +function appendBoundedLaunchLog(logLines: string[], message: string): void { + logLines.push(`[${new Date().toISOString()}] ${message}`); + if (logLines.length > 80) { + logLines.splice(0, logLines.length - 80); + } +} + +function formatLaunchErrorForCopy(error: LaunchErrorState, gameTitle: string): string { + return [ + "OpenNOW session launch error", + `Game: ${gameTitle}`, + `Stage: ${error.stage}`, + `Title: ${error.title}`, + `Description: ${error.description}`, + error.codeLabel ? `Code: ${error.codeLabel}` : null, + ...(error.debugDetails ?? []), + `Time: ${error.occurredAtIso}`, + ].filter((line): line is string => Boolean(line)).join("\n"); +} + export function App(): JSX.Element { // Auth State @@ -795,6 +1120,8 @@ export function App(): JSX.Element { const [searchQuery, setSearchQuery] = useState(""); const [selectedGameId, setSelectedGameId] = useState(""); const [variantByGameId, setVariantByGameId] = useState>({}); + const [launchStorePickerGame, setLaunchStorePickerGame] = useState(null); + const [pendingLaunchIntent, setPendingLaunchIntent] = useState(null); const [isLoadingGames, setIsLoadingGames] = useState(false); const [catalogFilterGroups, setCatalogFilterGroups] = useState([]); const [catalogSortOptions, setCatalogSortOptions] = useState([]); @@ -839,6 +1166,7 @@ export function App(): JSX.Element { autoFullScreen: false, favoriteGameIds: [], sessionCounterEnabled: false, + hideFreeTierSessionWarnings: false, sessionClockShowEveryMinutes: 60, sessionClockShowDurationSeconds: 30, windowWidth: 1400, @@ -849,10 +1177,12 @@ export function App(): JSX.Element { enableCloudGsync: false, discordRichPresence: false, autoCheckForUpdates: true, + androidTouchControls: normalizeAndroidTouchSettings(undefined), }); const [settingsLoaded, setSettingsLoaded] = useState(false); const [codecResults, setCodecResults] = useState(() => loadStoredCodecResults()); const [codecTesting, setCodecTesting] = useState(false); + const [androidPerformanceInfo, setAndroidPerformanceInfo] = useState(null); const [regions, setRegions] = useState([]); const [subscriptionInfo, setSubscriptionInfo] = useState(null); const diagnosticsStoreRef = useRef | null>(null); @@ -878,6 +1208,7 @@ export function App(): JSX.Element { const [logoutConfirmOpen, setLogoutConfirmOpen] = useState(false); const [launchError, setLaunchError] = useState(null); const [queueModalGame, setQueueModalGame] = useState(null); + const [queueModalVariantId, setQueueModalVariantId] = useState(null); const [queueModalData, setQueueModalData] = useState(null); const [sessionStartedAtMs, setSessionStartedAtMs] = useState(null); const [remoteStreamWarning, setRemoteStreamWarning] = useState(null); @@ -889,7 +1220,10 @@ export function App(): JSX.Element { const sessionElapsedSeconds = useElapsedSeconds(sessionStartedAtMs, streamStatus === "streaming"); const isStreaming = streamStatus === "streaming"; const freeTierSessionWarningsActive = - isStreaming && sessionStartedAtMs !== null && shouldShowFreeTierSessionWarnings(subscriptionInfo); + isStreaming && + sessionStartedAtMs !== null && + !settings.hideFreeTierSessionWarnings && + shouldShowFreeTierSessionWarnings(subscriptionInfo); const freeTierSessionRemainingSeconds = freeTierSessionWarningsActive ? Math.max(0, FREE_TIER_SESSION_LIMIT_SECONDS - sessionElapsedSeconds) : null; @@ -911,6 +1245,57 @@ export function App(): JSX.Element { const codecTestPromiseRef = useRef | null>(null); const codecStartupTestAttemptedRef = useRef(false); const navbarSessionActionInFlightRef = useRef<"resume" | "terminate" | null>(null); + const androidStreamCompatibilityReason = useMemo(() => getAndroidStreamCompatibilityReason(), []); + const lowPowerAndroidTouchControls = useMemo( + () => shouldUseLowPowerAndroidTouchControls(androidStreamCompatibilityReason, androidPerformanceInfo), + [androidPerformanceInfo, androidStreamCompatibilityReason], + ); + const preferAndroidMouseInput = useMemo( + () => androidGamePrefersMouseInput(streamingGame), + [streamingGame], + ); + const effectiveStreamPreferences = useMemo( + () => getEffectiveStreamPreferences(settings, androidStreamCompatibilityReason), + [androidStreamCompatibilityReason, settings], + ); + + useEffect(() => { + if (!platformCapabilities.isAndroid || !openNow.getAndroidPerformanceInfo) { + return; + } + + let cancelled = false; + void openNow.getAndroidPerformanceInfo() + .then((info) => { + if (!cancelled) { + setAndroidPerformanceInfo(info); + } + }) + .catch((error) => { + console.warn("[Android] Failed to read native performance info:", error); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!effectiveStreamPreferences.compatibilityReason) { + return; + } + if ( + settings.codec === effectiveStreamPreferences.codec && + settings.colorQuality === effectiveStreamPreferences.colorQuality && + settings.resolution === effectiveStreamPreferences.resolution && + settings.maxBitrateMbps <= effectiveStreamPreferences.maxBitrateMbps && + settings.fps <= effectiveStreamPreferences.fps + ) { + return; + } + console.log( + `[Android] Using H264/8-bit stream compatibility profile for ${effectiveStreamPreferences.compatibilityReason}`, + ); + }, [effectiveStreamPreferences, settings.codec, settings.colorQuality, settings.fps, settings.maxBitrateMbps]); const resetStatsOverlayToPreference = useCallback((): void => { setShowStatsOverlay(settings.showStatsOnLaunch); @@ -1004,10 +1389,6 @@ export function App(): JSX.Element { window.dispatchEvent(new CustomEvent("opennow:controller-direction", { detail: { direction } })); return true; } - if (settings.controllerMode && currentPage === "settings") { - window.dispatchEvent(new CustomEvent("opennow:controller-direction", { detail: { direction } })); - return true; - } return false; }, [authSession, currentPage, settings.controllerMode, streamStatus]); @@ -1023,10 +1404,6 @@ export function App(): JSX.Element { window.dispatchEvent(new CustomEvent("opennow:controller-activate")); return true; } - if (settings.controllerMode && currentPage === "settings") { - window.dispatchEvent(new CustomEvent("opennow:controller-activate")); - return true; - } return false; }, [authSession, currentPage, settings.controllerMode, streamStatus]); @@ -1042,10 +1419,6 @@ export function App(): JSX.Element { window.dispatchEvent(new CustomEvent("opennow:controller-secondary-activate")); return true; } - if (settings.controllerMode && currentPage === "settings") { - window.dispatchEvent(new CustomEvent("opennow:controller-secondary-activate")); - return true; - } return false; }, [authSession, currentPage, settings.controllerMode, streamStatus]); @@ -1061,10 +1434,6 @@ export function App(): JSX.Element { window.dispatchEvent(new CustomEvent("opennow:controller-tertiary-activate")); return true; } - if (settings.controllerMode && currentPage === "settings") { - window.dispatchEvent(new CustomEvent("opennow:controller-tertiary-activate")); - return true; - } return false; }, [authSession, currentPage, settings.controllerMode, streamStatus]); @@ -1079,10 +1448,13 @@ export function App(): JSX.Element { && streamStatus === "idle" && settings.controllerMode && (currentPage === "library" || currentPage === "settings"); - const controllerUiActive = controllerDesktopModeActive || controllerOverlayOpen; + const androidTvLoginNavigationActive = platformCapabilities.isAndroid && !authSession && streamStatus === "idle"; + const androidShellNavigationActive = platformCapabilities.isAndroid && streamStatus === "idle"; + const controllerNavigationActive = controllerDesktopModeActive || controllerOverlayOpen || androidShellNavigationActive; + const controllerUiActive = controllerDesktopModeActive || controllerOverlayOpen || androidTvLoginNavigationActive; const controllerConnected = useControllerNavigation({ - enabled: controllerUiActive, + enabled: controllerNavigationActive, onNavigatePage: handleControllerPageNavigate, onBackAction: handleControllerBackAction, onDirectionInput: handleControllerDirectionInput, @@ -1090,6 +1462,33 @@ export function App(): JSX.Element { onSecondaryActivateInput: handleControllerSecondaryActivateInput, onTertiaryActivateInput: handleControllerTertiaryActivateInput, }); + + useEffect(() => { + if (!platformCapabilities.isAndroid) return; + + let cancelled = false; + let removeListener: (() => void) | null = null; + + void CapacitorApp.addListener("backButton", () => { + handleControllerBackAction(); + }).then((handle) => { + if (cancelled) { + void handle.remove(); + return; + } + removeListener = () => { + void handle.remove(); + }; + }).catch((error) => { + console.warn("[Android] Failed to register back-button handler:", error); + }); + + return () => { + cancelled = true; + removeListener?.(); + }; + }, [handleControllerBackAction]); + const showControllerHint = controllerUiActive && controllerConnected && !(settings.controllerMode && currentPage === "library"); @@ -1111,12 +1510,15 @@ export function App(): JSX.Element { raf = window.requestAnimationFrame(tick); return; } - // Meta/Home button only: button 16 (standard) - const metaPressed = Boolean(pad.buttons[16]?.pressed); - if (metaPressed && !prev.pressed) { + // On Android/TV the Xbox guide button also opens the remote Steam overlay. + // Keep that button on the stream path; use View+Menu for OpenNOW's menu. + const openNowMenuPressed = platformCapabilities.isAndroid + ? Boolean(pad.buttons[8]?.pressed && pad.buttons[9]?.pressed) + : Boolean(pad.buttons[16]?.pressed); + if (openNowMenuPressed && !prev.pressed) { setControllerOverlayOpen((v) => !v); } - prev.pressed = metaPressed; + prev.pressed = openNowMenuPressed; } catch { // ignore } @@ -1132,9 +1534,11 @@ export function App(): JSX.Element { const clientRef = useRef(null); const sessionRef = useRef(null); const hasInitializedRef = useRef(false); + const suppressAutoGameLoadRef = useRef(false); const regionsRequestRef = useRef(0); const launchInFlightRef = useRef(false); const launchAbortRef = useRef(false); + const lastHandledLaunchIntentSequenceRef = useRef(null); const streamStatusRef = useRef(streamStatus); const exitPromptResolverRef = useRef<((confirmed: boolean) => void) | null>(null); const adReportQueueRef = useRef>(Promise.resolve()); @@ -1502,7 +1906,7 @@ export function App(): JSX.Element { `watchedTimeInMs=${request.watchedTimeInMs ?? "n/a"}, pausedTimeInMs=${request.pausedTimeInMs ?? 0}, ` + `cancelReason=${request.cancelReason ?? "n/a"}, errorInfo=${request.errorInfo ?? "n/a"}`, ); - const updated = await window.openNow.reportSessionAd(request); + const updated = await openNow.reportSessionAd(request); if (sessionRef.current?.sessionId !== updated.sessionId) { return; } @@ -1666,7 +2070,7 @@ export function App(): JSX.Element { const loadSubscriptionInfo = useCallback( async (session: AuthSession): Promise => { const token = session.tokens.idToken ?? session.tokens.accessToken; - const subscription = await window.openNow.fetchSubscription({ + const subscription = await openNow.fetchSubscription({ token, providerStreamingBaseUrl: session.provider.streamingServiceUrl, userId: session.user.userId, @@ -1687,7 +2091,7 @@ export function App(): JSX.Element { return; } try { - const activeSessions = await window.openNow.getActiveSessions(token, effectiveStreamingBaseUrl); + const activeSessions = await openNow.getActiveSessions(token, effectiveStreamingBaseUrl); const candidate = activeSessions.find((entry) => entry.status === 3 || entry.status === 2) ?? null; setNavbarActiveSession(candidate); } catch (error) { @@ -1737,7 +2141,7 @@ export function App(): JSX.Element { console.warn("Skipping session stop: missing auth token"); return false; } - await window.openNow.stopSession({ + await openNow.stopSession({ token, streamingBaseUrl: target.streamingBaseUrl, serverIp: target.serverIp, @@ -1774,7 +2178,7 @@ export function App(): JSX.Element { const initialize = async () => { try { // Load settings first - const loadedSettings = await window.openNow.getSettings(); + const loadedSettings = await migrateLegacyAndroidTouchSettings(await openNow.getSettings()); setSettings(loadedSettings); setShowStatsOverlay(loadedSettings.showStatsOnLaunch); setSettingsLoaded(true); @@ -1782,8 +2186,8 @@ export function App(): JSX.Element { // Load providers and session (refresh only if token is near expiry) setStartupStatusMessage("Restoring saved session..."); const [providerList, sessionResult] = await Promise.all([ - window.openNow.getLoginProviders(), - window.openNow.getAuthSession(), + openNow.getLoginProviders(), + openNow.getAuthSession(), ]); const persistedSession = sessionResult.session; @@ -1821,6 +2225,7 @@ export function App(): JSX.Element { } // Update isInitializing FIRST so UI knows we're done loading + suppressAutoGameLoadRef.current = Boolean(persistedSession); setIsInitializing(false); setProviders(providerList); setAuthSession(persistedSession); @@ -1831,7 +2236,7 @@ export function App(): JSX.Element { if (persistedSession) { // Load regions const token = persistedSession.tokens.idToken ?? persistedSession.tokens.accessToken; - const discovered = await window.openNow.getRegions({ token }); + const discovered = await openNow.getRegions({ token }); setRegions(discovered); try { @@ -1844,14 +2249,14 @@ export function App(): JSX.Element { // Load games try { const [catalogResult, libGames] = await Promise.all([ - window.openNow.browseCatalog({ + openNow.browseCatalog({ token, providerStreamingBaseUrl: persistedSession.provider.streamingServiceUrl, searchQuery: "", sortId: catalogSelectedSortId, filterIds: catalogSelectedFilterIds, }), - window.openNow.fetchLibraryGames({ + openNow.fetchLibraryGames({ token, providerStreamingBaseUrl: persistedSession.provider.streamingServiceUrl, }), @@ -1867,8 +2272,11 @@ export function App(): JSX.Element { setCatalogSortOptions([]); setCatalogTotalCount(0); setCatalogSupportedCount(0); + } finally { + suppressAutoGameLoadRef.current = false; } } else { + suppressAutoGameLoadRef.current = false; setGames([]); setLibraryGames([]); setSubscriptionInfo(null); @@ -1937,6 +2345,10 @@ export function App(): JSX.Element { settings.shortcutToggleRecording, ]); + const isSessionFullscreenActive = useCallback(() => { + return Boolean(document.fullscreenElement) || document.body.dataset.androidFullscreen === "true"; + }, []); + const setSessionFullscreen = useCallback(async (nextFullscreen: boolean) => { try { if (nextFullscreen) { @@ -1949,18 +2361,23 @@ export function App(): JSX.Element { } catch {} try { - await window.openNow.setFullscreen(nextFullscreen); + await openNow.setFullscreen(nextFullscreen); } catch (error) { console.warn(`Failed to sync native fullscreen state (${nextFullscreen ? "enter" : "exit"}):`, error); } }, []); const toggleSessionFullscreen = useCallback(async () => { - await setSessionFullscreen(!document.fullscreenElement); - }, [setSessionFullscreen]); + await setSessionFullscreen(!isSessionFullscreenActive()); + }, [isSessionFullscreenActive, setSessionFullscreen]); const requestEscLockedPointerCapture = useCallback(async (target: HTMLVideoElement) => { const lockTarget = (target.parentElement as HTMLElement | null) ?? target; + if (platformCapabilities.isAndroid || typeof lockTarget.requestPointerLock !== "function") { + target.focus(); + return; + } + const requestPointerLockCompat = async ( options?: { unadjustedMovement?: boolean }, ): Promise => { @@ -1970,7 +2387,7 @@ export function App(): JSX.Element { } }; - if (settings.autoFullScreen && !document.fullscreenElement) { + if (settings.autoFullScreen && !isSessionFullscreenActive()) { await setSessionFullscreen(true); } @@ -1989,7 +2406,7 @@ export function App(): JSX.Element { throw err; }) .catch(() => {}); - }, [setSessionFullscreen, settings.autoFullScreen]); + }, [isSessionFullscreenActive, setSessionFullscreen, settings.autoFullScreen]); const handleRequestPointerLock = useCallback(() => { if (videoRef.current) { @@ -2038,7 +2455,7 @@ export function App(): JSX.Element { // Listen for F11 fullscreen toggle from main process (uses W3C Fullscreen API // so navigator.keyboard.lock() can capture Escape in fullscreen) useEffect(() => { - const unsubscribe = window.openNow.onToggleFullscreen(() => { + const unsubscribe = openNow.onToggleFullscreen(() => { void toggleSessionFullscreen(); }); return () => unsubscribe(); @@ -2048,18 +2465,19 @@ export function App(): JSX.Element { useEffect(() => { const isSessionConnecting = streamStatus === "connecting" || streamStatus === "streaming"; - if (!settings.autoFullScreen || !isSessionConnecting) { + const shouldEnterFullscreen = settings.autoFullScreen || platformCapabilities.isAndroid; + if (!shouldEnterFullscreen || !isSessionConnecting) { autoFullscreenRequestedRef.current = false; return; } - if (autoFullscreenRequestedRef.current || document.fullscreenElement) { + if (autoFullscreenRequestedRef.current || isSessionFullscreenActive()) { return; } autoFullscreenRequestedRef.current = true; void setSessionFullscreen(true); - }, [setSessionFullscreen, settings.autoFullScreen, streamStatus]); + }, [isSessionFullscreenActive, setSessionFullscreen, settings.autoFullScreen, streamStatus]); // Anti-AFK interval useEffect(() => { @@ -2178,7 +2596,7 @@ export function App(): JSX.Element { // Signaling events useEffect(() => { - const unsubscribe = window.openNow.onSignalingEvent(async (event: MainToRendererSignalingEvent) => { + const unsubscribe = openNow.onSignalingEvent(async (event: MainToRendererSignalingEvent) => { console.log(`[App] Signaling event: ${event.type}`, event.type === "offer" ? `(SDP ${event.sdp.length} chars)` : "", event.type === "remote-ice" ? event.candidate : ""); try { if (event.type === "offer") { @@ -2229,11 +2647,11 @@ export function App(): JSX.Element { if (clientRef.current) { await clientRef.current.handleOffer(event.sdp, activeSession, { - codec: settings.codec, - colorQuality: settings.colorQuality, - resolution: settings.resolution, - fps: settings.fps, - maxBitrateKbps: settings.maxBitrateMbps * 1000, + codec: effectiveStreamPreferences.codec, + colorQuality: effectiveStreamPreferences.colorQuality, + resolution: effectiveStreamPreferences.resolution, + fps: effectiveStreamPreferences.fps, + maxBitrateKbps: effectiveStreamPreferences.maxBitrateMbps * 1000, }); setLaunchError(null); setStreamStatus("streaming"); @@ -2255,13 +2673,13 @@ export function App(): JSX.Element { }); return () => unsubscribe(); - }, [resetLaunchRuntime, settings]); + }, [effectiveStreamPreferences, resetLaunchRuntime, settings]); // Save settings when changed const updateSetting = useCallback(async (key: K, value: Settings[K]) => { setSettings((prev) => ({ ...prev, [key]: value })); if (settingsLoaded) { - await window.openNow.setSetting(key, value); + await openNow.setSetting(key, value); } // If a running client exists, push certain settings live if (key === "mouseSensitivity") { @@ -2318,8 +2736,8 @@ export function App(): JSX.Element { if (settingsLoaded) { void Promise.all([ - window.openNow.setSetting("controllerMode", false), - window.openNow.setSetting("autoLoadControllerLibrary", false), + openNow.setSetting("controllerMode", false), + openNow.setSetting("autoLoadControllerLibrary", false), ]).catch((error) => { console.warn("Failed to persist controller mode exit settings:", error); }); @@ -2327,7 +2745,8 @@ export function App(): JSX.Element { }, [settingsLoaded]); const handleExitApp = useCallback(() => { - void window.openNow.quitApp().catch((error) => { + if (!platformCapabilities.supportsQuitApp) return; + void openNow.quitApp().catch((error) => { console.warn("Failed to quit application:", error); }); }, []); @@ -2356,13 +2775,14 @@ export function App(): JSX.Element { setIsLoggingIn(true); setLoginError(null); try { - const session = await window.openNow.login({ providerIdpId: providerIdpId || undefined }); + const session = await openNow.login({ providerIdpId: providerIdpId || undefined }); + suppressAutoGameLoadRef.current = true; setAuthSession(session); setProviderIdpId(session.provider.idpId); // Load regions const token = session.tokens.idToken ?? session.tokens.accessToken; - const discovered = await window.openNow.getRegions({ token }); + const discovered = await openNow.getRegions({ token }); setRegions(discovered); try { @@ -2373,14 +2793,14 @@ export function App(): JSX.Element { } const [catalogResult, libGames] = await Promise.all([ - window.openNow.browseCatalog({ + openNow.browseCatalog({ token, providerStreamingBaseUrl: session.provider.streamingServiceUrl, searchQuery: "", sortId: catalogSelectedSortId, filterIds: catalogSelectedFilterIds, }), - window.openNow.fetchLibraryGames({ + openNow.fetchLibraryGames({ token, providerStreamingBaseUrl: session.provider.streamingServiceUrl, }), @@ -2391,13 +2811,14 @@ export function App(): JSX.Element { } catch (error) { setLoginError(error instanceof Error ? error.message : "Login failed"); } finally { + suppressAutoGameLoadRef.current = false; setIsLoggingIn(false); } }, [applyCatalogBrowseResult, applyVariantSelections, loadSubscriptionInfo, providerIdpId, catalogFilterKey, catalogSelectedSortId]); const confirmLogout = useCallback(async () => { setLogoutConfirmOpen(false); - await window.openNow.logout(); + await openNow.logout(); setAuthSession(null); setGames([]); setLibraryGames([]); @@ -2432,7 +2853,7 @@ export function App(): JSX.Element { } if (targetSource === "main") { - const catalogResult = await window.openNow.browseCatalog({ + const catalogResult = await openNow.browseCatalog({ token, providerStreamingBaseUrl: baseUrl, searchQuery, @@ -2443,7 +2864,7 @@ export function App(): JSX.Element { return; } - const result = await window.openNow.fetchLibraryGames({ token, providerStreamingBaseUrl: baseUrl }); + const result = await openNow.fetchLibraryGames({ token, providerStreamingBaseUrl: baseUrl }); setLibraryGames(result); setSelectedGameId((previous) => result.some((game) => game.id === previous) ? previous : (result[0]?.id ?? "")); applyVariantSelections(result); @@ -2455,7 +2876,7 @@ export function App(): JSX.Element { }, [applyCatalogBrowseResult, applyVariantSelections, authSession, effectiveStreamingBaseUrl, searchQuery, catalogFilterKey, catalogSelectedSortId]); useEffect(() => { - if (!authSession || currentPage !== "home") { + if (!authSession || currentPage !== "home" || suppressAutoGameLoadRef.current) { return; } const handle = window.setTimeout(() => { @@ -2496,18 +2917,18 @@ export function App(): JSX.Element { setStreamingStore(null); } - const claimed = await window.openNow.claimSession({ + const claimed = await openNow.claimSession({ token, streamingBaseUrl: effectiveStreamingBaseUrl, serverIp: existingSession.serverIp, sessionId: existingSession.sessionId, appId: String(existingSession.appId), settings: { - resolution: settings.resolution, - fps: settings.fps, - maxBitrateMbps: settings.maxBitrateMbps, - codec: settings.codec, - colorQuality: settings.colorQuality, + resolution: effectiveStreamPreferences.resolution, + fps: effectiveStreamPreferences.fps, + maxBitrateMbps: effectiveStreamPreferences.maxBitrateMbps, + codec: effectiveStreamPreferences.codec, + colorQuality: effectiveStreamPreferences.colorQuality, keyboardLayout: settings.keyboardLayout, gameLanguage: settings.gameLanguage, enableL4S: settings.enableL4S, @@ -2528,15 +2949,15 @@ export function App(): JSX.Element { sessionRef.current = claimed; setQueuePosition(undefined); setStreamStatus("connecting"); - await window.openNow.connectSignaling({ + await openNow.connectSignaling({ sessionId: claimed.sessionId, signalingServer: claimed.signalingServer, signalingUrl: claimed.signalingUrl, }); - }, [authSession, effectiveStreamingBaseUrl, findGameContextForSession, resetStatsOverlayToPreference, settings]); + }, [authSession, effectiveStreamPreferences, effectiveStreamingBaseUrl, findGameContextForSession, resetStatsOverlayToPreference, settings]); // Play game handler - const handlePlayGame = useCallback(async (game: GameInfo, options?: { bypassGuards?: boolean; streamingBaseUrl?: string }) => { + const handlePlayGame = useCallback(async (game: GameInfo, options?: PlayGameOptions) => { if (!selectedProvider) return; console.log("handlePlayGame entry", { @@ -2568,17 +2989,21 @@ export function App(): JSX.Element { setLocalSessionTimerWarning(null); setLaunchError(null); resetStatsOverlayToPreference(); - const selectedVariantId = variantByGameId[game.id] ?? defaultVariantId(game); + const selectedVariantId = options?.variantId ?? variantByGameId[game.id] ?? defaultVariantId(game); const selectedVariant = getSelectedVariant(game, selectedVariantId); + let appId: string | null = null; startPlaytimeSession(game.id); updateLoadingStep("queue"); setQueuePosition(undefined); + const launchLogLines: string[] = []; + const logLaunch = (message: string): void => appendBoundedLaunchLog(launchLogLines, message); + logLaunch(`Launch requested for "${game.title}" (${game.id})`); + try { const token = authSession?.tokens.idToken ?? authSession?.tokens.accessToken; // Resolve appId - let appId: string | null = null; if (isNumericId(selectedVariantId)) { appId = selectedVariantId; } else if (isNumericId(game.launchAppId)) { @@ -2587,7 +3012,7 @@ export function App(): JSX.Element { if (!appId && token) { try { - const resolved = await window.openNow.resolveLaunchAppId({ + const resolved = await openNow.resolveLaunchAppId({ token, providerStreamingBaseUrl: effectiveStreamingBaseUrl, appIdOrUuid: game.uuid ?? selectedVariantId, @@ -2601,10 +3026,11 @@ export function App(): JSX.Element { } if (!appId) { - throw new Error("Could not resolve numeric appId for this game"); + throw new Error("Could not resolve a positive numeric appId for this game"); } const numericAppId = Number(appId); + logLaunch(`Resolved appId=${numericAppId}, selectedVariantId=${selectedVariantId}, store=${selectedVariant?.store ?? "n/a"}`); const matchedGameContext = findSessionContextForAppId(allKnownGames, variantByGameId, numericAppId) ?? { game, variant: selectedVariant, @@ -2617,7 +3043,7 @@ export function App(): JSX.Element { // Check for active sessions first if (token) { try { - const activeSessions = await window.openNow.getActiveSessions(token, effectiveStreamingBaseUrl); + const activeSessions = await openNow.getActiveSessions(token, effectiveStreamingBaseUrl); if (activeSessions.length > 0) { // Only claim sessions that are already paused/ready (status 2 or 3). // Status=1 sessions are still in queue/setup; sending a RESUME claim @@ -2633,7 +3059,7 @@ export function App(): JSX.Element { } if (otherSession) { - const choice = await window.openNow.showSessionConflictDialog(); + const choice = await openNow.showSessionConflictDialog(); if (choice === "cancel") { resetLaunchRuntime(); return; @@ -2655,7 +3081,10 @@ export function App(): JSX.Element { } // Create new session - const newSession = await window.openNow.createSession({ + logLaunch( + `Creating session via ${options?.streamingBaseUrl || effectiveStreamingBaseUrl}, zone=prod, existingSessionStrategy=${existingSessionStrategy ?? "default"}`, + ); + const newSession = await openNow.createSession({ token: token || undefined, streamingBaseUrl: options?.streamingBaseUrl || effectiveStreamingBaseUrl, appId, @@ -2664,11 +3093,11 @@ export function App(): JSX.Element { existingSessionStrategy, zone: "prod", settings: { - resolution: settings.resolution, - fps: settings.fps, - maxBitrateMbps: settings.maxBitrateMbps, - codec: settings.codec, - colorQuality: settings.colorQuality, + resolution: effectiveStreamPreferences.resolution, + fps: effectiveStreamPreferences.fps, + maxBitrateMbps: effectiveStreamPreferences.maxBitrateMbps, + codec: effectiveStreamPreferences.codec, + colorQuality: effectiveStreamPreferences.colorQuality, keyboardLayout: settings.keyboardLayout, gameLanguage: settings.gameLanguage, enableL4S: settings.enableL4S, @@ -2678,6 +3107,9 @@ export function App(): JSX.Element { setSession(newSession); setQueuePosition(newSession.queuePosition); + logLaunch( + `Session created: sessionId=${newSession.sessionId}, status=${newSession.status}, queuePosition=${newSession.queuePosition ?? "n/a"}, serverIp=${newSession.serverIp ?? "n/a"}, zone=${newSession.zone ?? "n/a"}`, + ); // Poll for readiness. // Queue and setup/starting modes wait indefinitely until the session becomes ready @@ -2738,7 +3170,7 @@ export function App(): JSX.Element { return; } - const polled = await window.openNow.pollSession({ + const polled = await openNow.pollSession({ token: token || undefined, streamingBaseUrl: newSession.streamingBaseUrl ?? effectiveStreamingBaseUrl, serverIp: newSession.serverIp, @@ -2764,6 +3196,9 @@ export function App(): JSX.Element { console.log( `Poll attempt ${attempt}: status=${mergedSession.status}, seatSetupStep=${mergedSession.seatSetupStep ?? "n/a"}, queuePosition=${mergedSession.queuePosition ?? "n/a"}, serverIp=${mergedSession.serverIp}, queueMode=${isInQueueMode}, adsRequired=${isSessionAdsRequired(mergedSession.adState)}`, ); + logLaunch( + `Poll ${attempt}: status=${mergedSession.status}, seatSetupStep=${mergedSession.seatSetupStep ?? "n/a"}, queuePosition=${mergedSession.queuePosition ?? "n/a"}, serverIp=${mergedSession.serverIp ?? "n/a"}, queueMode=${isInQueueMode}, adsRequired=${isSessionAdsRequired(mergedSession.adState)}`, + ); if (isSessionReadyForConnect(mergedSession.status)) { finalSession = mergedSession; @@ -2796,7 +3231,7 @@ export function App(): JSX.Element { status: sessionToConnect.status, }); - await window.openNow.connectSignaling({ + await openNow.connectSignaling({ sessionId: sessionToConnect.sessionId, signalingServer: sessionToConnect.signalingServer, signalingUrl: sessionToConnect.signalingUrl, @@ -2806,8 +3241,20 @@ export function App(): JSX.Element { return; } console.error("Launch failed:", error); - setLaunchError(toLaunchErrorState(error, loadingStep)); - await window.openNow.disconnectSignaling().catch(() => {}); + const launchErrorState = toLaunchErrorState(error, loadingStep); + launchErrorState.debugDetails = [ + ...(launchErrorState.debugDetails ?? []), + `Game id: ${game.id}`, + `Game uuid: ${game.uuid ?? "n/a"}`, + `Selected variant id: ${selectedVariantId}`, + `Selected store: ${selectedVariant?.store ?? "n/a"}`, + `Game launchAppId: ${game.launchAppId ?? "n/a"}`, + `Resolved appId: ${appId ?? "n/a"}`, + `Streaming base: ${options?.streamingBaseUrl || effectiveStreamingBaseUrl}`, + ...launchLogLines.map((line) => `Launch log: ${line}`), + ]; + setLaunchError(launchErrorState); + await openNow.disconnectSignaling().catch(() => {}); clientRef.current?.dispose(); clientRef.current = null; resetLaunchRuntime({ keepLaunchError: true, keepStreamingContext: true }); @@ -2819,6 +3266,7 @@ export function App(): JSX.Element { authSession, allKnownGames, claimAndConnectSession, + effectiveStreamPreferences, effectiveStreamingBaseUrl, refreshNavbarActiveSession, resetLaunchRuntime, @@ -2829,23 +3277,20 @@ export function App(): JSX.Element { variantByGameId, ]); - // Gate handler: shows queue server modal for FREE-tier users before launching - const handleInitiatePlay = useCallback(async (game: GameInfo) => { - const effectiveTier = normalizeMembershipTier( - subscriptionInfo?.membershipTier ?? authSession?.user.membershipTier, - ); - const isFreeUser = effectiveTier === "FREE"; + const handleLaunchAfterStoreSelection = useCallback(async (game: GameInfo, variantId?: string) => { + const shouldUsePrintedWasteGate = shouldShowQueueAdsForMembership(subscriptionInfo, authSession); const isAllianceServer = isAllianceStreamingBaseUrl(effectiveStreamingBaseUrl); if (isAllianceServer) { setQueueModalData(null); - void handlePlayGame(game); + setQueueModalVariantId(null); + void handlePlayGame(game, { variantId }); return; } - if (isFreeUser && streamStatus === "idle" && !launchInFlightRef.current) { + if (shouldUsePrintedWasteGate && streamStatus === "idle" && !launchInFlightRef.current) { try { const [queueResult, mappingResult] = await Promise.allSettled([ - window.openNow.fetchPrintedWasteQueue(), - window.openNow.fetchPrintedWasteServerMapping(), + openNow.fetchPrintedWasteQueue(), + openNow.fetchPrintedWasteServerMapping(), ]); if (queueResult.status !== "fulfilled" || mappingResult.status !== "fulfilled") { @@ -2857,14 +3302,16 @@ export function App(): JSX.Element { }, ); setQueueModalData(null); - void handlePlayGame(game); + setQueueModalVariantId(null); + void handlePlayGame(game, { variantId }); return; } const queueData = queueResult.value; if (!queueData || Object.keys(queueData).length === 0) { setQueueModalData(null); - void handlePlayGame(game); + setQueueModalVariantId(null); + void handlePlayGame(game, { variantId }); return; } @@ -2873,39 +3320,148 @@ export function App(): JSX.Element { "[QueueServerSelect] No eligible non-nuked PrintedWaste zones available, skipping queue checks.", ); setQueueModalData(null); - void handlePlayGame(game); + setQueueModalVariantId(null); + void handlePlayGame(game, { variantId }); return; } setQueueModalData(queueData); setQueueModalGame(game); + setQueueModalVariantId(variantId ?? null); } catch (error) { console.warn("[QueueServerSelect] PrintedWaste queue checks failed, launching without modal.", error); setQueueModalData(null); - void handlePlayGame(game); + setQueueModalVariantId(null); + void handlePlayGame(game, { variantId }); } return; } - void handlePlayGame(game); + void handlePlayGame(game, { variantId }); }, [subscriptionInfo, authSession, streamStatus, handlePlayGame, effectiveStreamingBaseUrl]); + // Gate handler: asks for a store on multi-store games, then shows queue server modal for FREE-tier users before launching. + const handleInitiatePlay = useCallback(async (game: GameInfo) => { + if (streamStatus === "idle" && !launchInFlightRef.current && hasMultipleLaunchStoreOptions(game)) { + setLaunchStorePickerGame(game); + return; + } + await handleLaunchAfterStoreSelection(game); + }, [handleLaunchAfterStoreSelection, streamStatus]); + const handleQueueModalConfirm = useCallback((zoneUrl: string | null) => { const game = queueModalGame; + const variantId = queueModalVariantId ?? undefined; setQueueModalGame(null); + setQueueModalVariantId(null); setQueueModalData(null); if (game) { - void handlePlayGame(game, { streamingBaseUrl: zoneUrl ?? undefined }); + void handlePlayGame(game, { streamingBaseUrl: zoneUrl ?? undefined, variantId }); } - }, [queueModalGame, handlePlayGame]); + }, [queueModalGame, queueModalVariantId, handlePlayGame]); const handleQueueModalCancel = useCallback(() => { setQueueModalGame(null); + setQueueModalVariantId(null); setQueueModalData(null); }, []); + useEffect(() => { + if (!platformCapabilities.isAndroid) { + return; + } + + let active = true; + const enqueueLaunchIntent = (intent: AndroidLaunchIntent): void => { + if (!active) { + return; + } + setPendingLaunchIntent((previous) => previous?.sequence === intent.sequence ? previous : intent); + }; + + const unsubscribe = openNow.onLaunchIntent(enqueueLaunchIntent); + void openNow.consumeLaunchIntent() + .then((intent) => { + if (intent) { + enqueueLaunchIntent(intent); + } + }) + .catch((error) => { + console.warn("[Android Intent] Failed to consume pending launch intent:", error); + }); + + return () => { + active = false; + unsubscribe(); + }; + }, []); + + useEffect(() => { + if (!pendingLaunchIntent) { + return; + } + if (lastHandledLaunchIntentSequenceRef.current === pendingLaunchIntent.sequence) { + return; + } + if (isInitializing) { + return; + } + if (!authSession || !selectedProvider) { + console.warn("[Android Intent] Launch intent received before sign-in; waiting for an authenticated session.", pendingLaunchIntent); + return; + } + if (launchInFlightRef.current || streamStatus !== "idle" || navbarSessionActionInFlightRef.current) { + console.warn("[Android Intent] Ignoring launch intent because a session action is already active.", { + sequence: pendingLaunchIntent.sequence, + streamStatus, + launchInFlight: launchInFlightRef.current, + navbarSessionAction: navbarSessionActionInFlightRef.current, + }); + lastHandledLaunchIntentSequenceRef.current = pendingLaunchIntent.sequence; + setPendingLaunchIntent(null); + return; + } + + const intentGame = gameFromAndroidLaunchIntent(pendingLaunchIntent, allKnownGames, variantByGameId); + if (!intentGame) { + console.warn("[Android Intent] Ignoring launch intent without a numeric appId.", pendingLaunchIntent); + lastHandledLaunchIntentSequenceRef.current = pendingLaunchIntent.sequence; + setPendingLaunchIntent(null); + return; + } + + console.log("[Android Intent] Launching game from intent.", { + sequence: pendingLaunchIntent.sequence, + appId: pendingLaunchIntent.appId, + title: intentGame.title, + source: pendingLaunchIntent.source, + }); + lastHandledLaunchIntentSequenceRef.current = pendingLaunchIntent.sequence; + setPendingLaunchIntent(null); + void handleInitiatePlay(intentGame); + }, [ + allKnownGames, + authSession, + handleInitiatePlay, + isInitializing, + pendingLaunchIntent, + selectedProvider, + streamStatus, + variantByGameId, + ]); + useEffect(() => { if (!logoutConfirmOpen) return; + const focusFirstAction = window.setTimeout(() => { + const firstAction = document.querySelector(".logout-confirm-btn-cancel"); + if (!firstAction) return; + document.querySelectorAll(".controller-focus").forEach((node) => { + node.classList.remove("controller-focus"); + }); + firstAction.classList.add("controller-focus"); + firstAction.focus({ preventScroll: true }); + }, 0); + const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setLogoutConfirmOpen(false); @@ -2921,17 +3477,33 @@ export function App(): JSX.Element { document.body.style.overflow = "hidden"; return () => { + window.clearTimeout(focusFirstAction); window.removeEventListener("keydown", handleKeyDown); document.body.style.overflow = previousOverflow; }; }, [confirmLogout, logoutConfirmOpen]); + const handleLaunchStorePickerCancel = useCallback(() => { + setLaunchStorePickerGame(null); + }, []); + + const handleLaunchStorePickerConfirm = useCallback((variantId: string) => { + const game = launchStorePickerGame; + if (!game) { + return; + } + handleSelectGameVariant(game.id, variantId); + setLaunchStorePickerGame(null); + void handleLaunchAfterStoreSelection(game, variantId); + }, [handleLaunchAfterStoreSelection, handleSelectGameVariant, launchStorePickerGame]); + const logoutConfirmModal = logoutConfirmOpen && typeof document !== "undefined" ? createPortal(
+ ); +} diff --git a/opennow-stable/src/renderer/src/components/GameCard.tsx b/opennow-stable/src/renderer/src/components/GameCard.tsx index 49dedc31..472ff0f8 100644 --- a/opennow-stable/src/renderer/src/components/GameCard.tsx +++ b/opennow-stable/src/renderer/src/components/GameCard.tsx @@ -8,11 +8,12 @@ interface GameCardProps { isSelected?: boolean; onPlay: () => void; onSelect: () => void; + playOnSelect?: boolean; selectedVariantId?: string; onSelectStore?: (variantId: string) => void; } -interface StoreOption { +export interface StoreOption { storeKey: string; variantId: string; displayName: string; @@ -151,7 +152,7 @@ export function getStoreIconComponent(store: string): () => JSX.Element { return STORE_ICON_MAP[key] ?? DefaultStoreIcon; } -function getStoreOptions(game: GameInfo): StoreOption[] { +export function getStoreOptions(game: GameInfo): StoreOption[] { const seen = new Set(); const options: StoreOption[] = []; for (const variant of game.variants) { @@ -189,6 +190,7 @@ export const GameCard = memo(function GameCard({ isSelected = false, onPlay, onSelect, + playOnSelect = false, selectedVariantId, onSelectStore, }: GameCardProps): JSX.Element { @@ -212,6 +214,14 @@ export const GameCard = memo(function GameCard({ onPlay(); }; + const handleCardClick = (): void => { + if (playOnSelect) { + onPlay(); + return; + } + onSelect(); + }; + const handleStoreClick = (event: React.MouseEvent, variantId: string): void => { event.stopPropagation(); onSelectStore?.(variantId); @@ -220,7 +230,7 @@ export const GameCard = memo(function GameCard({ return (
{ if (event.target !== event.currentTarget) { return; @@ -232,7 +242,7 @@ export const GameCard = memo(function GameCard({ }} role="button" tabIndex={0} - aria-label={`Select ${game.title}`} + aria-label={`${playOnSelect ? "Play" : "Select"} ${game.title}`} >
void; + playOnCardSelect?: boolean; selectedVariantByGameId: Record; onSelectGameVariant: (gameId: string, variantId: string) => void; filterGroups: CatalogFilterGroup[]; @@ -31,6 +32,7 @@ export function HomePage({ isLoading, selectedGameId, onSelectGame, + playOnCardSelect = false, selectedVariantByGameId, onSelectGameVariant, filterGroups, @@ -138,6 +140,7 @@ export function HomePage({ isSelected={game.id === selectedGameId} onSelect={() => onSelectGame(game.id)} onPlay={() => onPlayGame(game)} + playOnSelect={playOnCardSelect} selectedVariantId={selectedVariantByGameId[game.id]} onSelectStore={(variantId) => onSelectGameVariant(game.id, variantId)} /> diff --git a/opennow-stable/src/renderer/src/components/LaunchStorePickerModal.tsx b/opennow-stable/src/renderer/src/components/LaunchStorePickerModal.tsx new file mode 100644 index 00000000..50054fab --- /dev/null +++ b/opennow-stable/src/renderer/src/components/LaunchStorePickerModal.tsx @@ -0,0 +1,120 @@ +import { Check, Play, X } from "lucide-react"; +import { useEffect, useMemo, useRef } from "react"; +import type { JSX } from "react"; +import { createPortal } from "react-dom"; +import type { GameInfo } from "@shared/gfn"; +import { getStoreOptions } from "./GameCard"; + +interface LaunchStorePickerModalProps { + game: GameInfo; + selectedVariantId?: string; + onSelectVariant: (variantId: string) => void; + onConfirm: (variantId: string) => void; + onCancel: () => void; +} + +export function hasMultipleLaunchStoreOptions(game: GameInfo): boolean { + return getStoreOptions(game).length > 1; +} + +export function LaunchStorePickerModal({ + game, + selectedVariantId, + onSelectVariant, + onConfirm, + onCancel, +}: LaunchStorePickerModalProps): JSX.Element | null { + const storeOptions = useMemo(() => getStoreOptions(game), [game]); + const activeVariantId = storeOptions.some((option) => option.variantId === selectedVariantId) + ? selectedVariantId + : storeOptions[0]?.variantId; + const launchButtonRef = useRef(null); + + useEffect(() => { + const focusTimer = window.setTimeout(() => launchButtonRef.current?.focus(), 0); + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === "Escape") { + onCancel(); + } + }; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + window.addEventListener("keydown", handleKeyDown); + return () => { + window.clearTimeout(focusTimer); + window.removeEventListener("keydown", handleKeyDown); + document.body.style.overflow = previousOverflow; + }; + }, [onCancel]); + + if (storeOptions.length <= 1 || !activeVariantId || typeof document === "undefined") { + return null; + } + + const activeStore = storeOptions.find((option) => option.variantId === activeVariantId) ?? storeOptions[0]; + + return createPortal( +
+ +
+ +
+ {storeOptions.map((option) => { + const selected = option.variantId === activeVariantId; + return ( + + ); + })} +
+ +
+ + +
+
+
, + document.body, + ); +} diff --git a/opennow-stable/src/renderer/src/components/LibraryPage.tsx b/opennow-stable/src/renderer/src/components/LibraryPage.tsx index 55408dbf..e49dde1d 100644 --- a/opennow-stable/src/renderer/src/components/LibraryPage.tsx +++ b/opennow-stable/src/renderer/src/components/LibraryPage.tsx @@ -11,6 +11,7 @@ export interface LibraryPageProps { isLoading: boolean; selectedGameId: string; onSelectGame: (id: string) => void; + playOnCardSelect?: boolean; selectedVariantByGameId: Record; onSelectGameVariant: (gameId: string, variantId: string) => void; libraryCount: number; @@ -46,6 +47,7 @@ export function LibraryPage({ isLoading, selectedGameId, onSelectGame, + playOnCardSelect = false, selectedVariantByGameId, onSelectGameVariant, libraryCount, @@ -113,6 +115,7 @@ export function LibraryPage({ isSelected={game.id === selectedGameId} onSelect={() => onSelectGame(game.id)} onPlay={() => onPlayGame(game)} + playOnSelect={playOnCardSelect} selectedVariantId={selectedVariantByGameId[game.id]} onSelectStore={(variantId) => onSelectGameVariant(game.id, variantId)} /> diff --git a/opennow-stable/src/renderer/src/components/QueueServerSelectModal.tsx b/opennow-stable/src/renderer/src/components/QueueServerSelectModal.tsx index 39e35051..362b4b31 100644 --- a/opennow-stable/src/renderer/src/components/QueueServerSelectModal.tsx +++ b/opennow-stable/src/renderer/src/components/QueueServerSelectModal.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { JSX } from "react"; import type { GameInfo, PrintedWasteQueueData, PrintedWasteZone } from "@shared/gfn"; +import { openNow } from "../platform"; // ── Constants / helpers ─────────────────────────────────────────────────────── @@ -133,7 +134,7 @@ export function QueueServerSelectModal({ game, initialQueueData = null, onConfir let cancelled = false; void (async () => { try { - const data = await window.openNow.fetchPrintedWasteQueue(); + const data = await openNow.fetchPrintedWasteQueue(); if (!cancelled) setQueueData(data); } catch { if (!cancelled) setFetchError("Could not load queue data. You can still launch with default routing."); @@ -153,7 +154,7 @@ export function QueueServerSelectModal({ game, initialQueueData = null, onConfir if (inFlight) return; inFlight = true; try { - const data = await window.openNow.fetchPrintedWasteQueue(); + const data = await openNow.fetchPrintedWasteQueue(); if (cancelled) return; setQueueData(data); setFetchError(null); @@ -183,7 +184,7 @@ export function QueueServerSelectModal({ game, initialQueueData = null, onConfir let cancelled = false; void (async () => { try { - const mapping = await window.openNow.fetchPrintedWasteServerMapping(); + const mapping = await openNow.fetchPrintedWasteServerMapping(); if (cancelled) return; const nextNuked = new Set(); for (const [zoneId, meta] of Object.entries(mapping)) { @@ -267,7 +268,7 @@ export function QueueServerSelectModal({ game, initialQueueData = null, onConfir setIsPinging(true); void (async () => { try { - const results = await window.openNow.pingRegions(regionsToTest); + const results = await openNow.pingRegions(regionsToTest); if (cancelled) return; const map = new Map(seedMap); for (const r of results) map.set(r.url, r.pingMs); diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index 522d89f3..02f620bc 100644 --- a/opennow-stable/src/renderer/src/components/SettingsPage.tsx +++ b/opennow-stable/src/renderer/src/components/SettingsPage.tsx @@ -1,4 +1,4 @@ -import { Globe, Check, Search, X, Loader, Zap, Mic, FileDown, Wifi, Trash2, Heart, Users, ExternalLink, Monitor, Keyboard, Download, RefreshCcw, Info } from "lucide-react"; +import { Globe, Check, Search, X, Loader, Zap, Mic, FileDown, Wifi, Trash2, Heart, Users, ExternalLink, Monitor, Keyboard, Download, RefreshCcw, Info, Copy } from "lucide-react"; import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import type { JSX } from "react"; @@ -25,7 +25,9 @@ import { USER_FACING_VIDEO_CODEC_OPTIONS, } from "@shared/gfn"; import { formatShortcutForDisplay, normalizeShortcut, shortcutFromKeyboardEvent } from "../shortcuts"; +import { openNow, platformCapabilities } from "../platform"; import { getCodecDecodeBadgeState, type CodecTestResult } from "../lib/codecDiagnostics"; +import { copyTextToClipboard } from "../utils/clipboard"; interface SettingsPageProps { settings: Settings; @@ -37,6 +39,7 @@ interface SettingsPageProps { } type ThanksLoadState = "idle" | "loading" | "loaded" | "error"; +type DebugLogCopyState = "idle" | "copying" | "copied" | "failed"; type SettingsSectionId = "stream" | "game" | "audio" | "input" | "interface" | "about" | "thanks"; @@ -86,6 +89,7 @@ const STATIC_ASPECT_RATIO_PRESETS: AspectRatioPreset[] = [ const STATIC_RESOLUTION_PRESETS: ResolutionPreset[] = [ { value: "1280x720", label: "720p (16:9)" }, + { value: "1680x720", label: "Ultrawide 720p (21:9)" }, { value: "1280x800", label: "720p (16:10)" }, { value: "1440x900", label: "WXGA (16:10)" }, { value: "1680x1050", label: "WSXGA (16:10)" }, @@ -226,6 +230,7 @@ function classifyAspectRatio(width: number, height: number): string { function friendlyResolutionName(width: number, height: number): string { if (width === 1280 && height === 720) return "720p (HD)"; + if (width === 1680 && height === 720) return "1680x720 (UW)"; if (width === 1920 && height === 1080) return "1080p (FHD)"; if (width === 2560 && height === 1440) return "1440p (QHD)"; if (width === 3840 && height === 2160) return "4K (UHD)"; @@ -439,7 +444,7 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, if (regions.length === 0) return; setIsPinging(true); try { - const results = await window.openNow.pingRegions(regions); + const results = await openNow.pingRegions(regions); const pingMap = new Map(); let bestUrl: string | null = null; let bestPing = Infinity; @@ -564,7 +569,7 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, useEffect(() => { let cancelled = false; - void window.openNow.getUpdaterState().then((state) => { + void openNow.getUpdaterState().then((state) => { if (!cancelled) { setUpdaterState(state); } @@ -572,7 +577,7 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, console.warn("[Settings] Failed to load updater state:", error); }); - const unsubscribe = window.openNow.onUpdaterStateChanged((state) => { + const unsubscribe = openNow.onUpdaterStateChanged((state) => { if (!cancelled) { setUpdaterState(state); } @@ -590,7 +595,7 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, async function load(): Promise { try { - const sessionResult = await window.openNow.getAuthSession(); + const sessionResult = await openNow.getAuthSession(); const session = sessionResult.session; if (!session || cancelled) { setEntitledResolutions([]); @@ -606,7 +611,7 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, return; } - const sub = await window.openNow.fetchSubscription({ + const sub = await openNow.fetchSubscription({ userId, }); @@ -651,6 +656,38 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, ? `${formatBytes(updaterState.progress.bytesPerSecond)}/s` : null; const updaterBadgeLabel = useMemo(() => getUpdaterBadgeLabel(updaterState), [updaterState]); + const [debugLogCopyState, setDebugLogCopyState] = useState("idle"); + + const handleDebugLogs = useCallback(async (): Promise => { + try { + if (platformCapabilities.isAndroid) { + setDebugLogCopyState("copying"); + const logs = await openNow.exportLogs("text"); + await copyTextToClipboard(logs); + setDebugLogCopyState("copied"); + window.setTimeout(() => setDebugLogCopyState("idle"), 1800); + return; + } + + const logs = await openNow.exportLogs("text"); + const blob = new Blob([logs], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `opennow-logs-${new Date().toISOString().replace(/[:.]/g, "-")}.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (err) { + console.error("[Settings] Failed to export logs:", err); + if (platformCapabilities.isAndroid) { + setDebugLogCopyState("failed"); + window.setTimeout(() => setDebugLogCopyState("idle"), 2200); + } + alert(platformCapabilities.isAndroid ? "Failed to copy logs. Please try again." : "Failed to export logs. Please try again."); + } + }, []); const selectedResolutionLabel = useMemo(() => { if (hasDynamic) { @@ -737,8 +774,8 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, }; try { - if (typeof window.openNow?.getMicrophonePermission === "function") { - const permission = await window.openNow.getMicrophonePermission(); + if (typeof openNow?.getMicrophonePermission === "function") { + const permission = await openNow.getMicrophonePermission(); if (cancelled) { return; } @@ -1108,7 +1145,7 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, let requestPromise: Promise; try { - const getThanksData = window.openNow?.getThanksData; + const getThanksData = openNow?.getThanksData; if (typeof getThanksData !== "function") { throw new Error("openNow.getThanksData is unavailable"); } @@ -1700,7 +1737,7 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, className="settings-slider" min={5} max={150} - step={5} + step={1} value={settings.maxBitrateMbps} onChange={(e) => handleChange("maxBitrateMbps", parseInt(e.target.value, 10))} /> @@ -2131,209 +2168,210 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, Dynamic turn boost strength (1% = off-like, 150% = strongest).
- {/* Shortcuts */} -
-
- -
- Editable - + {platformCapabilities.supportsKeyboardShortcuts && ( +
+
+ +
+ Editable + +
-
-
-
- Toggle Stats - e.target.select()} - onBlur={() => handleShortcutBlur("shortcutToggleStats", toggleStatsInput)} - onPaste={(e) => handleShortcutPaste("shortcutToggleStats", e)} - onKeyDown={(e) => handleShortcutCaptureKeyDown("shortcutToggleStats", e)} - placeholder="Click here, then press a key" - title="Focus and press the key combination to bind" - spellCheck={false} - /> -
+
+
+ Toggle Stats + e.target.select()} + onBlur={() => handleShortcutBlur("shortcutToggleStats", toggleStatsInput)} + onPaste={(e) => handleShortcutPaste("shortcutToggleStats", e)} + onKeyDown={(e) => handleShortcutCaptureKeyDown("shortcutToggleStats", e)} + placeholder="Click here, then press a key" + title="Focus and press the key combination to bind" + spellCheck={false} + /> +
-
- Mouse Lock - e.target.select()} - onBlur={() => handleShortcutBlur("shortcutTogglePointerLock", togglePointerLockInput)} - onPaste={(e) => handleShortcutPaste("shortcutTogglePointerLock", e)} - onKeyDown={(e) => handleShortcutCaptureKeyDown("shortcutTogglePointerLock", e)} - placeholder="Click here, then press a key" - title="Focus and press the key combination to bind" - spellCheck={false} - /> -
+
+ Mouse Lock + e.target.select()} + onBlur={() => handleShortcutBlur("shortcutTogglePointerLock", togglePointerLockInput)} + onPaste={(e) => handleShortcutPaste("shortcutTogglePointerLock", e)} + onKeyDown={(e) => handleShortcutCaptureKeyDown("shortcutTogglePointerLock", e)} + placeholder="Click here, then press a key" + title="Focus and press the key combination to bind" + spellCheck={false} + /> +
-
- Toggle Full Screen - e.target.select()} - onBlur={() => handleShortcutBlur("shortcutToggleFullscreen", toggleFullscreenInput)} - onPaste={(e) => handleShortcutPaste("shortcutToggleFullscreen", e)} - onKeyDown={(e) => handleShortcutCaptureKeyDown("shortcutToggleFullscreen", e)} - placeholder="Click here, then press a key" - title="Focus and press the key combination to bind" - spellCheck={false} - /> -
+
+ Toggle Full Screen + e.target.select()} + onBlur={() => handleShortcutBlur("shortcutToggleFullscreen", toggleFullscreenInput)} + onPaste={(e) => handleShortcutPaste("shortcutToggleFullscreen", e)} + onKeyDown={(e) => handleShortcutCaptureKeyDown("shortcutToggleFullscreen", e)} + placeholder="Click here, then press a key" + title="Focus and press the key combination to bind" + spellCheck={false} + /> +
-
- Stop Stream - e.target.select()} - onBlur={() => handleShortcutBlur("shortcutStopStream", stopStreamInput)} - onPaste={(e) => handleShortcutPaste("shortcutStopStream", e)} - onKeyDown={(e) => handleShortcutCaptureKeyDown("shortcutStopStream", e)} - placeholder="Click here, then press a key" - title="Focus and press the key combination to bind" - spellCheck={false} - /> -
+
+ Stop Stream + e.target.select()} + onBlur={() => handleShortcutBlur("shortcutStopStream", stopStreamInput)} + onPaste={(e) => handleShortcutPaste("shortcutStopStream", e)} + onKeyDown={(e) => handleShortcutCaptureKeyDown("shortcutStopStream", e)} + placeholder="Click here, then press a key" + title="Focus and press the key combination to bind" + spellCheck={false} + /> +
-
- Toggle Anti-AFK - e.target.select()} - onBlur={() => handleShortcutBlur("shortcutToggleAntiAfk", toggleAntiAfkInput)} - onPaste={(e) => handleShortcutPaste("shortcutToggleAntiAfk", e)} - onKeyDown={(e) => handleShortcutCaptureKeyDown("shortcutToggleAntiAfk", e)} - placeholder="Click here, then press a key" - title="Focus and press the key combination to bind" - spellCheck={false} - /> -
+
+ Toggle Anti-AFK + e.target.select()} + onBlur={() => handleShortcutBlur("shortcutToggleAntiAfk", toggleAntiAfkInput)} + onPaste={(e) => handleShortcutPaste("shortcutToggleAntiAfk", e)} + onKeyDown={(e) => handleShortcutCaptureKeyDown("shortcutToggleAntiAfk", e)} + placeholder="Click here, then press a key" + title="Focus and press the key combination to bind" + spellCheck={false} + /> +
-
- Toggle Microphone - e.target.select()} - onBlur={() => handleShortcutBlur("shortcutToggleMicrophone", toggleMicrophoneInput)} - onPaste={(e) => handleShortcutPaste("shortcutToggleMicrophone", e)} - onKeyDown={(e) => handleShortcutCaptureKeyDown("shortcutToggleMicrophone", e)} - placeholder="Click here, then press a key" - title="Focus and press the key combination to bind" - spellCheck={false} - /> -
+
+ Toggle Microphone + e.target.select()} + onBlur={() => handleShortcutBlur("shortcutToggleMicrophone", toggleMicrophoneInput)} + onPaste={(e) => handleShortcutPaste("shortcutToggleMicrophone", e)} + onKeyDown={(e) => handleShortcutCaptureKeyDown("shortcutToggleMicrophone", e)} + placeholder="Click here, then press a key" + title="Focus and press the key combination to bind" + spellCheck={false} + /> +
-
- Screenshot - e.target.select()} - onBlur={() => handleShortcutBlur("shortcutScreenshot", screenshotInput)} - onPaste={(e) => handleShortcutPaste("shortcutScreenshot", e)} - onKeyDown={(e) => handleShortcutCaptureKeyDown("shortcutScreenshot", e)} - placeholder="Click here, then press a key" - title="Focus and press the key combination to bind" - spellCheck={false} - /> -
+
+ Screenshot + e.target.select()} + onBlur={() => handleShortcutBlur("shortcutScreenshot", screenshotInput)} + onPaste={(e) => handleShortcutPaste("shortcutScreenshot", e)} + onKeyDown={(e) => handleShortcutCaptureKeyDown("shortcutScreenshot", e)} + placeholder="Click here, then press a key" + title="Focus and press the key combination to bind" + spellCheck={false} + /> +
-
- Recording - e.target.select()} - onBlur={() => handleShortcutBlur("shortcutToggleRecording", recordingInput)} - onPaste={(e) => handleShortcutPaste("shortcutToggleRecording", e)} - onKeyDown={(e) => handleShortcutCaptureKeyDown("shortcutToggleRecording", e)} - placeholder="Click here, then press a key" - title="Focus and press the key combination to bind" - spellCheck={false} - /> -
+
+ Recording + e.target.select()} + onBlur={() => handleShortcutBlur("shortcutToggleRecording", recordingInput)} + onPaste={(e) => handleShortcutPaste("shortcutToggleRecording", e)} + onKeyDown={(e) => handleShortcutCaptureKeyDown("shortcutToggleRecording", e)} + placeholder="Click here, then press a key" + title="Focus and press the key combination to bind" + spellCheck={false} + /> +
-
- Toggle stream sidebar - -
-
+
+ Toggle stream sidebar + +
+
- {(toggleStatsError || togglePointerLockError || toggleFullscreenError || stopStreamError || toggleAntiAfkError || toggleMicrophoneError || screenshotError || recordingError) && ( - - {toggleStatsError - || togglePointerLockError - || toggleFullscreenError - || stopStreamError - || toggleAntiAfkError - || toggleMicrophoneError - || screenshotError - || recordingError} - - )} + {(toggleStatsError || togglePointerLockError || toggleFullscreenError || stopStreamError || toggleAntiAfkError || toggleMicrophoneError || screenshotError || recordingError) && ( + + {toggleStatsError + || togglePointerLockError + || toggleFullscreenError + || stopStreamError + || toggleAntiAfkError + || toggleMicrophoneError + || screenshotError + || recordingError} + + )} - {!toggleStatsError && !togglePointerLockError && !toggleFullscreenError && !stopStreamError && !toggleAntiAfkError && !toggleMicrophoneError && !screenshotError && !recordingError && ( - - Click a field and press the keys to bind, or paste a shortcut ({shortcutExamples}). Escape cancels focus. Full screen: {formatShortcutForDisplay(settings.shortcutToggleFullscreen, isMac)}. Stop: {formatShortcutForDisplay(settings.shortcutStopStream, isMac)}. Mic: {formatShortcutForDisplay(settings.shortcutToggleMicrophone, isMac)}. Screenshot: {formatShortcutForDisplay(settings.shortcutScreenshot, isMac)}. Recording: {formatShortcutForDisplay(settings.shortcutToggleRecording, isMac)}. - - )} -
+ {!toggleStatsError && !togglePointerLockError && !toggleFullscreenError && !stopStreamError && !toggleAntiAfkError && !toggleMicrophoneError && !screenshotError && !recordingError && ( + + Click a field and press the keys to bind, or paste a shortcut ({shortcutExamples}). Escape cancels focus. Full screen: {formatShortcutForDisplay(settings.shortcutToggleFullscreen, isMac)}. Stop: {formatShortcutForDisplay(settings.shortcutStopStream, isMac)}. Mic: {formatShortcutForDisplay(settings.shortcutToggleMicrophone, isMac)}. Screenshot: {formatShortcutForDisplay(settings.shortcutScreenshot, isMac)}. Recording: {formatShortcutForDisplay(settings.shortcutToggleRecording, isMac)}. + + )} +
+ )}
)} @@ -2539,6 +2577,21 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, +
+ + +
+
@@ -2642,7 +2695,7 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, className="settings-export-logs-btn" disabled={!updaterState.canCheck} onClick={() => { - void window.openNow.checkForUpdates().catch((error) => { + void openNow.checkForUpdates().catch((error) => { console.error("[Settings] Failed to trigger update check:", error); }); }} @@ -2656,7 +2709,7 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, className="settings-export-logs-btn" disabled={!updaterState.canDownload} onClick={() => { - void window.openNow.downloadUpdate().catch((error) => { + void openNow.downloadUpdate().catch((error) => { console.error("[Settings] Failed to download update:", error); }); }} @@ -2671,7 +2724,7 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, className="settings-save-btn settings-save-btn--compact" disabled={!updaterState.canInstall} onClick={() => { - void window.openNow.installUpdateAndRestart().catch((error) => { + void openNow.installUpdateAndRestart().catch((error) => { console.error("[Settings] Failed to install update:", error); }); }} @@ -2711,62 +2764,69 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults,
) : null} -
- - -
+ {platformCapabilities.supportsLogExport && ( +
+ + +
+ )} -
- - -
+ {platformCapabilities.supportsCacheDeletion && ( +
+ + +
+ )}
)} diff --git a/opennow-stable/src/renderer/src/components/StreamLoading.tsx b/opennow-stable/src/renderer/src/components/StreamLoading.tsx index fec2a28c..fb242db8 100644 --- a/opennow-stable/src/renderer/src/components/StreamLoading.tsx +++ b/opennow-stable/src/renderer/src/components/StreamLoading.tsx @@ -11,6 +11,7 @@ import { } from "@shared/gfn"; import type { SessionAdInfo, SessionAdState } from "@shared/gfn"; import { getStoreDisplayName, getStoreIconComponent } from "./GameCard"; +import { CopyErrorButton } from "./CopyErrorButton"; import { QueueAdPreview, type QueueAdPlaybackEvent, type QueueAdPreviewHandle } from "./QueueAdPreview"; export interface StreamLoadingProps { @@ -28,6 +29,7 @@ export interface StreamLoadingProps { description: string; code?: string; }; + copyErrorText?: string; onAdPlaybackEvent?: (event: QueueAdPlaybackEvent, adId: string) => void; adPreviewRef?: Ref; onCancel: () => void; @@ -107,6 +109,7 @@ export function StreamLoading({ activeAd, activeAdMediaUrl, error, + copyErrorText, onAdPlaybackEvent, adPreviewRef, onCancel, @@ -213,6 +216,7 @@ export function StreamLoading({

{error.title}

{error.description}

{error.code &&

{error.code}

} + {copyErrorText && } )} {status === "queue" && estimatedWait && ( diff --git a/opennow-stable/src/renderer/src/components/StreamView.tsx b/opennow-stable/src/renderer/src/components/StreamView.tsx index 9acafdab..8c5629b3 100644 --- a/opennow-stable/src/renderer/src/components/StreamView.tsx +++ b/opennow-stable/src/renderer/src/components/StreamView.tsx @@ -1,16 +1,34 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { createPortal } from "react-dom"; -import type { JSX } from "react"; -import { Maximize, Minimize, Gamepad2, Loader2, LogOut, Clock3, AlertTriangle, Mic, MicOff, Camera, ChevronLeft, ChevronRight, Save, Trash2, X, Circle, Square, Video, FolderOpen } from "lucide-react"; +import type { CSSProperties, JSX } from "react"; +import { Maximize, Minimize, Gamepad2, Loader2, LogOut, Clock3, AlertTriangle, Mic, MicOff, Camera, ChevronLeft, ChevronRight, Save, Trash2, X, Circle, Square, Video, FolderOpen, Menu, Battery, Wifi, MousePointer2, Keyboard, CornerDownLeft, Delete } from "lucide-react"; import SideBar from "./SideBar"; import type { StreamDiagnosticsStore } from "../utils/streamDiagnosticsStore"; import { useStreamDiagnosticsSelector, useStreamDiagnosticsStore } from "../utils/streamDiagnosticsStore"; import type { StreamLagReason } from "../gfn/webrtcClient"; import type { MicState } from "../gfn/microphoneManager"; +import type { VirtualGamepadState } from "../gfn/webrtcClient"; +import { + GAMEPAD_A, + GAMEPAD_B, + GAMEPAD_BACK, + GAMEPAD_DPAD_DOWN, + GAMEPAD_DPAD_LEFT, + GAMEPAD_DPAD_RIGHT, + GAMEPAD_DPAD_UP, + GAMEPAD_LB, + GAMEPAD_RB, + GAMEPAD_START, + GAMEPAD_X, + GAMEPAD_Y, +} from "../gfn/inputProtocol"; import { getStoreDisplayName, getStoreIconComponent } from "./GameCard"; import { RemainingPlaytimeIndicator, SessionElapsedIndicator } from "./ElapsedSessionIndicators"; -import type { MicrophoneMode, ScreenshotEntry, RecordingEntry, SubscriptionInfo } from "@shared/gfn"; +import type { AndroidTouchPlacement, AndroidTouchSettings, MicrophoneMode, ScreenshotEntry, RecordingEntry, SubscriptionInfo } from "@shared/gfn"; +import { normalizeAndroidTouchSettings } from "@shared/settings"; import { formatShortcutForDisplay, isShortcutMatch, normalizeShortcut, shortcutFromKeyboardEvent } from "../shortcuts"; +import { openNow, platformCapabilities } from "../platform"; +import { useElapsedSeconds } from "../utils/useElapsedSeconds"; interface StreamViewProps { videoRef: React.Ref; @@ -64,12 +82,23 @@ interface StreamViewProps { onMouseAccelerationChange: (value: number) => void; onRequestPointerLock?: () => void; onReleasePointerLock?: () => void; + onVirtualGamepadState?: (state: VirtualGamepadState) => void; + onTouchMouseMove?: (input: { dx: number; dy: number; timestampMs?: number }) => void; + onTouchMouseTap?: (input: { timestampMs?: number }) => void; + onTouchMouseButton?: (input: { button: number; pressed: boolean; timestampMs?: number }) => void; + onTouchMouseWheel?: (input: { delta: number; timestampMs?: number }) => void; + onSendText?: (text: string) => number; + onSendKeyPress?: (key: "Backspace" | "Enter" | "Escape") => void; + androidTouchControls: AndroidTouchSettings; + onAndroidTouchControlsChange: (settings: AndroidTouchSettings) => void; microphoneMode: MicrophoneMode; onMicrophoneModeChange: (value: MicrophoneMode) => void; onScreenshotShortcutChange: (value: string) => void; onRecordingShortcutChange: (value: string) => void; subscriptionInfo: SubscriptionInfo | null; micTrack?: MediaStreamTrack | null; + lowPowerTouchControls?: boolean; + preferAndroidMouseInput?: boolean; className?: string; } @@ -167,6 +196,17 @@ type MicBadgeState = { micEnabled: boolean; }; +const EMPTY_VIRTUAL_GAMEPAD_STATE: VirtualGamepadState = { + connected: true, + buttons: 0, + leftTrigger: 0, + rightTrigger: 0, + leftStickX: 0, + leftStickY: 0, + rightStickX: 0, + rightStickY: 0, +}; + function isMicBadgeStateEqual(prev: MicBadgeState, next: MicBadgeState): boolean { return ( prev.connectedGamepads === next.connectedGamepads && @@ -523,6 +563,1042 @@ function VideoFocusOnReady({ return null; } +type StickValue = { x: number; y: number }; +const ANDROID_TOUCH_CONTROLLER_IDLE_DISCONNECT_MS = 3000; +const ANDROID_TOUCH_CONTROLLER_IDLE_HIDE_MS = 3500; +const ANDROID_TOUCH_CONTROLLER_KEEPALIVE_MS = 250; +const ANDROID_TOUCH_CONTROLLER_MIN_EMIT_MS = 24; +const ANDROID_TOUCH_STICK_STEP = 0.01; + +function clampNumber(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) { + return min; + } + return Math.max(min, Math.min(max, value)); +} + +function shouldUseDomPointerCapture(): boolean { + return !platformCapabilities.isAndroid; +} + +function isAndroidTvLikeSurface(): boolean { + if (!platformCapabilities.isAndroid) return false; + const userAgent = navigator.userAgent ?? ""; + return ( + navigator.maxTouchPoints === 0 || + /android tv|google tv|googletv|gtv|smart-?tv|bravia|aft\w*|shield android tv|crkey/i.test(userAgent) + ); +} + +function trySetPointerCapture(target: Element, pointerId: number): boolean { + if (!shouldUseDomPointerCapture() || typeof target.setPointerCapture !== "function") { + return false; + } + + try { + target.setPointerCapture(pointerId); + return typeof target.hasPointerCapture === "function" && target.hasPointerCapture(pointerId); + } catch { + return false; + } +} + +function tryReleasePointerCapture(target: Element, pointerId: number): void { + if (!shouldUseDomPointerCapture() || typeof target.releasePointerCapture !== "function") { + return; + } + + try { + if (typeof target.hasPointerCapture !== "function" || target.hasPointerCapture(pointerId)) { + target.releasePointerCapture(pointerId); + } + } catch { + // Android WebView can throw InvalidStateError for stale/non-captured pointers. + } +} + +function hasActivePointer(target: Element, pointerId: number, activePointerId: number | null): boolean { + if (activePointerId === pointerId) { + return true; + } + + try { + return typeof target.hasPointerCapture === "function" && target.hasPointerCapture(pointerId); + } catch { + return false; + } +} + +function quantizeTouchStickValue(value: StickValue): StickValue { + const quantizeAxis = (axis: number) => { + if (Math.abs(axis) < ANDROID_TOUCH_STICK_STEP) { + return 0; + } + return Math.round(axis / ANDROID_TOUCH_STICK_STEP) * ANDROID_TOUCH_STICK_STEP; + }; + return { x: quantizeAxis(value.x), y: quantizeAxis(value.y) }; +} + +function TouchStick({ + label, + onChange, +}: { + label: string; + onChange: (value: StickValue) => void; +}): JSX.Element { + const baseRef = useRef(null); + const thumbRef = useRef(null); + const activePointerIdRef = useRef(null); + + const setThumbPosition = useCallback((value: StickValue) => { + if (thumbRef.current) { + thumbRef.current.style.transform = `translate(${value.x * 28}px, ${value.y * 28}px)`; + } + }, []); + + const updateStick = useCallback((value: StickValue) => { + setThumbPosition(value); + onChange(value); + }, [onChange, setThumbPosition]); + + const updateFromPointer = useCallback((event: React.PointerEvent) => { + const rect = baseRef.current?.getBoundingClientRect(); + if (!rect) return; + const radius = Math.max(1, Math.min(rect.width, rect.height) / 2); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + const rawX = (event.clientX - centerX) / radius; + const rawY = (event.clientY - centerY) / radius; + const magnitude = Math.hypot(rawX, rawY); + const scale = magnitude > 1 ? 1 / magnitude : 1; + updateStick(quantizeTouchStickValue({ x: rawX * scale, y: rawY * scale })); + }, [updateStick]); + + useEffect(() => { + const releasePointer = (event: PointerEvent) => { + if (activePointerIdRef.current !== event.pointerId) { + return; + } + activePointerIdRef.current = null; + updateStick({ x: 0, y: 0 }); + }; + + window.addEventListener("pointerup", releasePointer); + window.addEventListener("pointercancel", releasePointer); + return () => { + window.removeEventListener("pointerup", releasePointer); + window.removeEventListener("pointercancel", releasePointer); + }; + }, [updateStick]); + + return ( +
{ + event.preventDefault(); + activePointerIdRef.current = event.pointerId; + trySetPointerCapture(event.currentTarget, event.pointerId); + updateFromPointer(event); + }} + onPointerMove={(event) => { + if (hasActivePointer(event.currentTarget, event.pointerId, activePointerIdRef.current)) { + event.preventDefault(); + updateFromPointer(event); + } + }} + onPointerUp={(event) => { + if (hasActivePointer(event.currentTarget, event.pointerId, activePointerIdRef.current)) { + event.preventDefault(); + tryReleasePointerCapture(event.currentTarget, event.pointerId); + activePointerIdRef.current = null; + updateStick({ x: 0, y: 0 }); + } + }} + onPointerCancel={(event) => { + if (hasActivePointer(event.currentTarget, event.pointerId, activePointerIdRef.current)) { + tryReleasePointerCapture(event.currentTarget, event.pointerId); + activePointerIdRef.current = null; + updateStick({ x: 0, y: 0 }); + } + }} + aria-label={label} + role="application" + > + +
+ ); +} + +function TouchControllerOverlay({ + onVirtualGamepadState, + settings, + revealSignal, + lowPowerMode = false, +}: { + onVirtualGamepadState: (state: VirtualGamepadState) => void; + settings: AndroidTouchSettings; + revealSignal?: number; + lowPowerMode?: boolean; +}): JSX.Element { + const overlayRef = useRef(null); + const [controlsVisible, setControlsVisible] = useState(true); + const controlsVisibleRef = useRef(true); + const buttonsRef = useRef(0); + const triggersRef = useRef({ left: 0, right: 0 }); + const leftStickRef = useRef({ x: 0, y: 0 }); + const rightStickRef = useRef({ x: 0, y: 0 }); + const virtualConnectedRef = useRef(false); + const lastTouchActivityMsRef = useRef(0); + const lastEmitMsRef = useRef(0); + const pendingEmitRef = useRef(null); + const hideControlsTimerRef = useRef(null); + const activeButtonPointersRef = useRef(new Map()); + const activeTriggerPointersRef = useRef(new Map()); + const minEmitMs = lowPowerMode ? 33 : ANDROID_TOUCH_CONTROLLER_MIN_EMIT_MS; + const keepaliveMs = lowPowerMode ? 500 : ANDROID_TOUCH_CONTROLLER_KEEPALIVE_MS; + + useEffect(() => { + console.log("[AndroidTouchOverlay] React overlay initialized", { + placement: settings.placement, + size: settings.size, + opacity: settings.opacity, + lowPowerMode, + viewport: `${window.innerWidth}x${window.innerHeight}`, + devicePixelRatio: window.devicePixelRatio, + }); + const frame = window.requestAnimationFrame(() => { + const rect = overlayRef.current?.getBoundingClientRect(); + if (!rect || rect.width <= 0 || rect.height <= 0) { + console.warn("[AndroidTouchOverlay] React overlay has no measurable layout", { + rect: rect ? { width: rect.width, height: rect.height } : null, + viewport: `${window.innerWidth}x${window.innerHeight}`, + devicePixelRatio: window.devicePixelRatio, + }); + } + }); + return () => { + window.cancelAnimationFrame(frame); + console.log("[AndroidTouchOverlay] React overlay unmounted"); + }; + }, [lowPowerMode, settings.opacity, settings.placement, settings.size]); + + useEffect(() => { + controlsVisibleRef.current = controlsVisible; + }, [controlsVisible]); + + const isNeutral = useCallback(() => ( + buttonsRef.current === 0 && + triggersRef.current.left === 0 && + triggersRef.current.right === 0 && + leftStickRef.current.x === 0 && + leftStickRef.current.y === 0 && + rightStickRef.current.x === 0 && + rightStickRef.current.y === 0 + ), []); + + const emitNow = useCallback((connected = virtualConnectedRef.current) => { + if (pendingEmitRef.current !== null) { + window.clearTimeout(pendingEmitRef.current); + pendingEmitRef.current = null; + } + lastEmitMsRef.current = performance.now(); + virtualConnectedRef.current = connected; + onVirtualGamepadState({ + connected, + buttons: connected ? buttonsRef.current : 0, + leftTrigger: connected ? triggersRef.current.left : 0, + rightTrigger: connected ? triggersRef.current.right : 0, + leftStickX: connected ? leftStickRef.current.x : 0, + leftStickY: connected ? -leftStickRef.current.y : 0, + rightStickX: connected ? rightStickRef.current.x : 0, + rightStickY: connected ? -rightStickRef.current.y : 0, + }); + }, [onVirtualGamepadState]); + + const scheduleControlsHide = useCallback(() => { + if (hideControlsTimerRef.current !== null) { + window.clearTimeout(hideControlsTimerRef.current); + hideControlsTimerRef.current = null; + } + hideControlsTimerRef.current = window.setTimeout(() => { + hideControlsTimerRef.current = null; + if (isNeutral()) { + setControlsVisible(false); + } + }, ANDROID_TOUCH_CONTROLLER_IDLE_HIDE_MS); + }, [isNeutral]); + + const revealControls = useCallback(() => { + setControlsVisible(true); + scheduleControlsHide(); + }, [scheduleControlsHide]); + + const scheduleEmit = useCallback((connected = virtualConnectedRef.current, immediate = false) => { + if (immediate) { + emitNow(connected); + return; + } + + const now = performance.now(); + const elapsed = now - lastEmitMsRef.current; + if (elapsed >= minEmitMs) { + emitNow(connected); + return; + } + + if (pendingEmitRef.current !== null) { + return; + } + + pendingEmitRef.current = window.setTimeout(() => { + pendingEmitRef.current = null; + emitNow(connected); + }, Math.max(1, minEmitMs - elapsed)); + }, [emitNow, minEmitMs]); + + const markTouchActivity = useCallback(() => { + lastTouchActivityMsRef.current = performance.now(); + if (!controlsVisibleRef.current) { + revealControls(); + } + if (!virtualConnectedRef.current) { + virtualConnectedRef.current = true; + } + }, [revealControls]); + + const updateLeftStick = useCallback((value: StickValue) => { + if (leftStickRef.current.x === value.x && leftStickRef.current.y === value.y) { + return; + } + leftStickRef.current = value; + markTouchActivity(); + scheduleEmit(true, value.x === 0 && value.y === 0); + }, [markTouchActivity, scheduleEmit]); + + const updateRightStick = useCallback((value: StickValue) => { + if (rightStickRef.current.x === value.x && rightStickRef.current.y === value.y) { + return; + } + rightStickRef.current = value; + markTouchActivity(); + scheduleEmit(true, value.x === 0 && value.y === 0); + }, [markTouchActivity, scheduleEmit]); + + const setButtonsFromActivePointers = useCallback(() => { + let nextButtons = 0; + for (const mask of activeButtonPointersRef.current.values()) { + nextButtons |= mask; + } + if (nextButtons === buttonsRef.current) { + return; + } + buttonsRef.current = nextButtons; + markTouchActivity(); + emitNow(true); + }, [emitNow, markTouchActivity]); + + const setTriggersFromActivePointers = useCallback(() => { + let left = 0; + let right = 0; + for (const side of activeTriggerPointersRef.current.values()) { + if (side === "left") { + left = 1; + } else { + right = 1; + } + } + if (triggersRef.current.left === left && triggersRef.current.right === right) { + return; + } + triggersRef.current = { left, right }; + markTouchActivity(); + emitNow(true); + }, [emitNow, markTouchActivity]); + + const bindButton = (mask: number) => ({ + onPointerDown: (event: React.PointerEvent) => { + event.preventDefault(); + trySetPointerCapture(event.currentTarget, event.pointerId); + activeButtonPointersRef.current.set(event.pointerId, mask); + setButtonsFromActivePointers(); + }, + onPointerUp: (event: React.PointerEvent) => { + event.preventDefault(); + tryReleasePointerCapture(event.currentTarget, event.pointerId); + activeButtonPointersRef.current.delete(event.pointerId); + setButtonsFromActivePointers(); + }, + onPointerCancel: (event: React.PointerEvent) => { + tryReleasePointerCapture(event.currentTarget, event.pointerId); + activeButtonPointersRef.current.delete(event.pointerId); + setButtonsFromActivePointers(); + }, + onContextMenu: (event: React.MouseEvent) => { + event.preventDefault(); + }, + }); + + const bindTrigger = (side: "left" | "right") => ({ + onPointerDown: (event: React.PointerEvent) => { + event.preventDefault(); + trySetPointerCapture(event.currentTarget, event.pointerId); + activeTriggerPointersRef.current.set(event.pointerId, side); + setTriggersFromActivePointers(); + }, + onPointerUp: (event: React.PointerEvent) => { + event.preventDefault(); + tryReleasePointerCapture(event.currentTarget, event.pointerId); + activeTriggerPointersRef.current.delete(event.pointerId); + setTriggersFromActivePointers(); + }, + onPointerCancel: (event: React.PointerEvent) => { + tryReleasePointerCapture(event.currentTarget, event.pointerId); + activeTriggerPointersRef.current.delete(event.pointerId); + setTriggersFromActivePointers(); + }, + onContextMenu: (event: React.MouseEvent) => { + event.preventDefault(); + }, + }); + + useEffect(() => { + const releasePointer = (event: PointerEvent) => { + let changed = false; + if (activeButtonPointersRef.current.delete(event.pointerId)) { + changed = true; + } + if (activeTriggerPointersRef.current.delete(event.pointerId)) { + changed = true; + } + if (!changed) { + return; + } + setButtonsFromActivePointers(); + setTriggersFromActivePointers(); + }; + + window.addEventListener("pointerup", releasePointer); + window.addEventListener("pointercancel", releasePointer); + return () => { + window.removeEventListener("pointerup", releasePointer); + window.removeEventListener("pointercancel", releasePointer); + }; + }, [setButtonsFromActivePointers, setTriggersFromActivePointers]); + + useEffect(() => { + revealControls(); + }, [revealControls, revealSignal]); + + useEffect(() => { + const keepalive = window.setInterval(() => { + if (!virtualConnectedRef.current) { + return; + } + if ( + isNeutral() && + performance.now() - lastTouchActivityMsRef.current >= ANDROID_TOUCH_CONTROLLER_IDLE_DISCONNECT_MS + ) { + emitNow(false); + return; + } + scheduleEmit(true); + }, keepaliveMs); + return () => { + if (pendingEmitRef.current !== null) { + window.clearTimeout(pendingEmitRef.current); + pendingEmitRef.current = null; + } + if (hideControlsTimerRef.current !== null) { + window.clearTimeout(hideControlsTimerRef.current); + hideControlsTimerRef.current = null; + } + window.clearInterval(keepalive); + onVirtualGamepadState({ ...EMPTY_VIRTUAL_GAMEPAD_STATE, connected: false }); + }; + }, [emitNow, isNeutral, keepaliveMs, onVirtualGamepadState, scheduleEmit]); + + return ( +
+
+ + + + +
+
+ +
+ + + + +
+
+
+ + +
+
+ +
+ + + + +
+
+
+ ); +} + +function AndroidMousePad({ + enabled, + onTouchMouseMove, + onTouchMouseTap, +}: { + enabled: boolean; + onTouchMouseMove?: (input: { dx: number; dy: number; timestampMs?: number }) => void; + onTouchMouseTap?: (input: { timestampMs?: number }) => void; +}): JSX.Element | null { + const padRef = useRef(null); + const lastPointRef = useRef<{ + x: number; + y: number; + startX: number; + startY: number; + startMs: number; + id: number; + } | null>(null); + const hoverPointRef = useRef<{ x: number; y: number } | null>(null); + const [cursor, setCursor] = useState({ x: 0, y: 0, visible: false }); + + const clampCursor = useCallback((x: number, y: number) => { + const rect = padRef.current?.getBoundingClientRect(); + if (!rect) { + return { x, y }; + } + return { + x: clampNumber(x, 0, rect.width), + y: clampNumber(y, 0, rect.height), + }; + }, []); + + const setCursorFromClientPoint = useCallback((clientX: number, clientY: number) => { + const rect = padRef.current?.getBoundingClientRect(); + if (!rect) return; + const next = clampCursor(clientX - rect.left, clientY - rect.top); + setCursor({ ...next, visible: true }); + }, [clampCursor]); + + const moveCursorBy = useCallback((dx: number, dy: number, clientX: number, clientY: number) => { + setCursor((previous) => { + if (!previous.visible) { + const rect = padRef.current?.getBoundingClientRect(); + if (!rect) return previous; + const next = clampCursor(clientX - rect.left, clientY - rect.top); + return { ...next, visible: true }; + } + const next = clampCursor(previous.x + dx, previous.y + dy); + return { ...next, visible: true }; + }); + }, [clampCursor]); + + if (!enabled || !onTouchMouseMove) { + return null; + } + + return ( +
{ + event.preventDefault(); + trySetPointerCapture(event.currentTarget, event.pointerId); + hoverPointRef.current = { x: event.clientX, y: event.clientY }; + if (event.pointerType === "mouse") { + setCursorFromClientPoint(event.clientX, event.clientY); + } else { + moveCursorBy(0, 0, event.clientX, event.clientY); + } + lastPointRef.current = { + x: event.clientX, + y: event.clientY, + startX: event.clientX, + startY: event.clientY, + startMs: event.timeStamp, + id: event.pointerId, + }; + }} + onPointerMove={(event) => { + const last = lastPointRef.current; + if (!last || last.id !== event.pointerId) { + if (event.pointerType === "mouse") { + const previous = hoverPointRef.current; + hoverPointRef.current = { x: event.clientX, y: event.clientY }; + setCursorFromClientPoint(event.clientX, event.clientY); + if (previous) { + const dx = event.clientX - previous.x; + const dy = event.clientY - previous.y; + if (dx !== 0 || dy !== 0) { + onTouchMouseMove({ dx, dy, timestampMs: event.timeStamp }); + } + } + } + return; + } + event.preventDefault(); + const dx = event.clientX - last.x; + const dy = event.clientY - last.y; + lastPointRef.current = { + ...last, + x: event.clientX, + y: event.clientY, + }; + hoverPointRef.current = { x: event.clientX, y: event.clientY }; + if (dx !== 0 || dy !== 0) { + moveCursorBy(dx, dy, event.clientX, event.clientY); + onTouchMouseMove({ dx, dy, timestampMs: event.timeStamp }); + } + }} + onPointerUp={(event) => { + const last = lastPointRef.current; + if (last?.id === event.pointerId) { + event.preventDefault(); + const movedPx = Math.hypot(event.clientX - last.startX, event.clientY - last.startY); + const elapsedMs = event.timeStamp - last.startMs; + tryReleasePointerCapture(event.currentTarget, event.pointerId); + lastPointRef.current = null; + hoverPointRef.current = { x: event.clientX, y: event.clientY }; + setCursorFromClientPoint(event.clientX, event.clientY); + if (movedPx <= 10 && elapsedMs <= 360) { + onTouchMouseTap?.({ timestampMs: event.timeStamp }); + } + } + }} + onPointerCancel={(event) => { + if (lastPointRef.current?.id === event.pointerId) { + tryReleasePointerCapture(event.currentTarget, event.pointerId); + lastPointRef.current = null; + } + }} + onPointerLeave={(event) => { + if (event.pointerType === "mouse" && !lastPointRef.current) { + hoverPointRef.current = null; + } + }} + > + +
+ ); +} + +type BatterySnapshot = { + level: number | null; + charging: boolean | null; +}; + +type BatteryManagerLike = EventTarget & { + level: number; + charging: boolean; +}; + +type NavigatorWithBattery = Navigator & { + getBattery?: () => Promise; +}; + +function useBatterySnapshot(): BatterySnapshot { + const [battery, setBattery] = useState({ level: null, charging: null }); + + useEffect(() => { + const getBattery = (navigator as NavigatorWithBattery).getBattery; + if (!getBattery) { + return; + } + + let dead = false; + let manager: BatteryManagerLike | null = null; + const sync = () => { + if (!manager || dead) { + return; + } + setBattery({ + level: Math.round(manager.level * 100), + charging: manager.charging, + }); + }; + + void getBattery.call(navigator).then((value) => { + if (dead) { + return; + } + manager = value; + sync(); + manager.addEventListener("levelchange", sync); + manager.addEventListener("chargingchange", sync); + }).catch(() => undefined); + + return () => { + dead = true; + manager?.removeEventListener("levelchange", sync); + manager?.removeEventListener("chargingchange", sync); + }; + }, []); + + return battery; +} + +function AndroidStreamMenu({ + diagnosticsStore, + sessionStartedAtMs, + isStreaming, + touchSettings, + onTouchSettingsChange, + onEndSession, + onCaptureScreenshot, + onSendText, + onSendKeyPress, + physicalGamepads, + preferMouseInput, + revealSignal, + onTouchControlsReveal, + streamZoom, + onStreamZoomChange, + screenshotAvailable, + isSavingScreenshot, +}: { + diagnosticsStore: StreamDiagnosticsStore; + sessionStartedAtMs: number | null; + isStreaming: boolean; + touchSettings: AndroidTouchSettings; + onTouchSettingsChange: (settings: AndroidTouchSettings) => void; + onEndSession: () => void; + onCaptureScreenshot: () => void; + onSendText?: (text: string) => number; + onSendKeyPress?: (key: "Backspace" | "Enter" | "Escape") => void; + physicalGamepads: number; + preferMouseInput: boolean; + revealSignal?: number; + onTouchControlsReveal?: () => void; + streamZoom: number; + onStreamZoomChange: (value: number) => void; + screenshotAvailable: boolean; + isSavingScreenshot: boolean; +}): JSX.Element { + const [open, setOpen] = useState(false); + const [visible, setVisible] = useState(true); + const battery = useBatterySnapshot(); + const elapsedSeconds = useElapsedSeconds(sessionStartedAtMs, isStreaming); + const textInputRef = useRef(null); + const hideTimerRef = useRef(null); + const lastRevealSignalRef = useRef(revealSignal); + const stats = useStreamDiagnosticsSelector( + diagnosticsStore, + (value) => ({ + rttMs: value.rttMs, + packetLossPercent: value.packetLossPercent, + bitrateKbps: value.bitrateKbps, + decodeFps: value.decodeFps, + connectionState: value.connectionState, + inputReady: value.inputReady, + lagReason: value.lagReason, + }), + (prev, next) => + prev.rttMs === next.rttMs && + prev.packetLossPercent === next.packetLossPercent && + prev.bitrateKbps === next.bitrateKbps && + prev.decodeFps === next.decodeFps && + prev.connectionState === next.connectionState && + prev.inputReady === next.inputReady && + prev.lagReason === next.lagReason, + ); + const updateTouchSettings = useCallback((patch: Partial) => { + onTouchSettingsChange({ ...touchSettings, ...patch }); + if (patch.enabled === true) { + onTouchControlsReveal?.(); + } + }, [onTouchControlsReveal, onTouchSettingsChange, touchSettings]); + const batteryText = battery.level === null ? "Battery --" : `Battery ${battery.level}%${battery.charging ? " charging" : ""}`; + const scheduleHide = useCallback((delayMs = 3500) => { + if (hideTimerRef.current !== null) { + window.clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + hideTimerRef.current = window.setTimeout(() => { + hideTimerRef.current = null; + setVisible(false); + }, delayMs); + }, []); + const reveal = useCallback((delayMs = 3500) => { + setVisible(true); + if (!open) { + scheduleHide(delayMs); + } + }, [open, scheduleHide]); + useEffect(() => { + if (open) { + setVisible(true); + if (hideTimerRef.current !== null) { + window.clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + return; + } + scheduleHide(); + }, [open, scheduleHide]); + useEffect(() => { + if (lastRevealSignalRef.current === revealSignal) { + return; + } + lastRevealSignalRef.current = revealSignal; + if (open) { + setOpen(false); + setVisible(true); + scheduleHide(); + return; + } + reveal(5000); + }, [open, revealSignal, reveal, scheduleHide]); + useEffect(() => () => { + if (hideTimerRef.current !== null) { + window.clearTimeout(hideTimerRef.current); + } + }, []); + const sendInputText = useCallback((input: HTMLInputElement) => { + const text = input.value; + input.value = ""; + if (text.length > 0) { + onSendText?.(text); + } + }, [onSendText]); + + return ( +
+ + {open && ( +
+
+ {formatElapsed(elapsedSeconds)} + {batteryText} + {stats.connectionState} · {stats.inputReady ? "input live" : "input sync"} +
+ +
+ RTT + {stats.rttMs > 0 ? `${stats.rttMs.toFixed(0)}ms` : "--"} + Loss + {stats.packetLossPercent.toFixed(2)}% + Bitrate + {(stats.bitrateKbps / 1000).toFixed(1)} Mbps + FPS + {stats.decodeFps || "--"} +
+ + + + + + + +
+ +
+ + + +
+
+ + + + + + + +
+ {(["default", "compact", "lower", "split"] as AndroidTouchPlacement[]).map((placement) => ( + + ))} +
+ + + + +
+ )} +
+ ); +} + function useMicMeter( canvasRef: React.RefObject, track: MediaStreamTrack | null, @@ -669,12 +1745,23 @@ export function StreamView({ onMouseAccelerationChange, onRequestPointerLock, onReleasePointerLock, + onVirtualGamepadState, + onTouchMouseMove, + onTouchMouseTap, + onTouchMouseButton, + onTouchMouseWheel, + onSendText, + onSendKeyPress, + androidTouchControls, + onAndroidTouchControlsChange, microphoneMode, onMicrophoneModeChange, onScreenshotShortcutChange, onRecordingShortcutChange, subscriptionInfo, micTrack, + lowPowerTouchControls = false, + preferAndroidMouseInput = false, hideStreamButtons = false, className, }: StreamViewProps): JSX.Element { @@ -683,6 +1770,8 @@ export function StreamView({ const [showSessionClock, setShowSessionClock] = useState(false); const [showSideBar, setShowSideBar] = useState(false); const [isPointerLocked, setIsPointerLocked] = useState(false); + const [hasVideoFrame, setHasVideoFrame] = useState(false); + const [streamZoom, setStreamZoom] = useState(1); const [screenshots, setScreenshots] = useState([]); const [isSavingScreenshot, setIsSavingScreenshot] = useState(false); const [galleryError, setGalleryError] = useState(null); @@ -691,10 +1780,12 @@ export function StreamView({ const [screenshotShortcutError, setScreenshotShortcutError] = useState(null); const [activeSidebarTab, setActiveSidebarTab] = useState<"preferences" | "shortcuts">("preferences"); const screenshotApiAvailable = - typeof window.openNow?.saveScreenshot === "function" && - typeof window.openNow?.listScreenshots === "function" && - typeof window.openNow?.deleteScreenshot === "function" && - typeof window.openNow?.saveScreenshotAs === "function"; + typeof openNow?.saveScreenshot === "function" && + typeof openNow?.listScreenshots === "function" && + typeof openNow?.deleteScreenshot === "function"; + const screenshotExportAvailable = + platformCapabilities.supportsScreenshotExport && + typeof openNow?.saveScreenshotAs === "function"; // Recording state const [isRecording, setIsRecording] = useState(false); @@ -704,19 +1795,241 @@ export function StreamView({ const [usedMimeType, setUsedMimeType] = useState(null); const [recordingShortcutInput, setRecordingShortcutInput] = useState(shortcuts.recording); const [recordingShortcutError, setRecordingShortcutError] = useState(null); + const androidTouchSettings = useMemo( + () => normalizeAndroidTouchSettings(androidTouchControls), + [androidTouchControls], + ); + const androidPhysicalGamepads = useStreamDiagnosticsSelector( + diagnosticsStore, + (stats) => stats.physicalGamepads, + ); + const [androidNativeTouchAvailable, setAndroidNativeTouchAvailable] = useState(true); + const hasVirtualGamepadHandler = Boolean(onVirtualGamepadState); + const androidTouchSurfaceAvailable = !isAndroidTvLikeSurface() && androidPhysicalGamepads === 0; + const androidMousePadEnabled = androidTouchSettings.mousePad || preferAndroidMouseInput; + const shouldAttemptAndroidNativeTouchControls = + platformCapabilities.isAndroid && + lowPowerTouchControls && + androidNativeTouchAvailable && + androidTouchSurfaceAvailable && + androidTouchSettings.enabled && + hasVirtualGamepadHandler; + const shouldRenderReactAndroidTouchControls = + platformCapabilities.isAndroid && + hasVirtualGamepadHandler && + androidTouchSurfaceAvailable && + androidTouchSettings.enabled && + (!lowPowerTouchControls || !androidNativeTouchAvailable); + const virtualGamepadStateRef = useRef(onVirtualGamepadState); + virtualGamepadStateRef.current = onVirtualGamepadState; + useEffect(() => { + if (!platformCapabilities.isAndroid) { + return; + } + + console.log("[AndroidTouchOverlay] render gate", { + enabled: androidTouchSettings.enabled, + lowPowerTouchControls, + preferAndroidMouseInput, + physicalGamepads: androidPhysicalGamepads, + touchSurfaceAvailable: androidTouchSurfaceAvailable, + nativeTouchAvailable: androidNativeTouchAvailable, + hasVirtualGamepadHandler, + hasNativeTouchApi: Boolean(openNow.setAndroidNativeTouchControls), + hasNativeTouchListener: Boolean(openNow.onAndroidNativeTouchGamepad), + reactOverlayWillRender: shouldRenderReactAndroidTouchControls, + nativeOverlayWillAttempt: shouldAttemptAndroidNativeTouchControls, + mousePadWillRender: androidMousePadEnabled, + viewport: `${window.innerWidth}x${window.innerHeight}`, + devicePixelRatio: window.devicePixelRatio, + }); + }, [ + androidNativeTouchAvailable, + androidPhysicalGamepads, + androidTouchSettings.enabled, + androidTouchSurfaceAvailable, + androidMousePadEnabled, + hasVirtualGamepadHandler, + lowPowerTouchControls, + preferAndroidMouseInput, + shouldAttemptAndroidNativeTouchControls, + shouldRenderReactAndroidTouchControls, + ]); + useEffect(() => { + if ( + !platformCapabilities.isAndroid || + !lowPowerTouchControls || + !androidTouchSurfaceAvailable || + !androidTouchSettings.enabled || + !virtualGamepadStateRef.current + ) { + return; + } + + if (!openNow.setAndroidNativeTouchControls || !openNow.onAndroidNativeTouchGamepad) { + console.warn("[AndroidTouchOverlay] Native touch controls unavailable; falling back to React overlay"); + setAndroidNativeTouchAvailable(false); + return; + } + + if (!androidNativeTouchAvailable) { + return; + } + + let lastNativeGamepadState: VirtualGamepadState | null = null; + let disposed = false; + console.log("[AndroidTouchOverlay] Native overlay initializing", { + placement: androidTouchSettings.placement, + size: androidTouchSettings.size, + opacity: androidTouchSettings.opacity, + viewport: `${window.innerWidth}x${window.innerHeight}`, + devicePixelRatio: window.devicePixelRatio, + }); + const removeNativeTouchListener = openNow.onAndroidNativeTouchGamepad((event) => { + lastNativeGamepadState = { + connected: event.connected, + buttons: event.buttons, + leftTrigger: event.leftTrigger, + rightTrigger: event.rightTrigger, + leftStickX: event.leftStickX, + leftStickY: event.leftStickY, + rightStickX: event.rightStickX, + rightStickY: event.rightStickY, + }; + virtualGamepadStateRef.current?.(lastNativeGamepadState); + }); + const keepalive = window.setInterval(() => { + if (lastNativeGamepadState?.connected) { + virtualGamepadStateRef.current?.(lastNativeGamepadState); + } + }, 500); + void openNow.setAndroidNativeTouchControls({ + enabled: true, + size: androidTouchSettings.size, + opacity: androidTouchSettings.opacity, + placement: androidTouchSettings.placement, + }).then((attached) => { + if (disposed) { + return; + } + if (attached) { + console.log("[AndroidTouchOverlay] Native overlay attached"); + return; + } + console.warn("[AndroidTouchOverlay] Native overlay did not attach; falling back to React overlay"); + setAndroidNativeTouchAvailable(false); + virtualGamepadStateRef.current?.({ ...EMPTY_VIRTUAL_GAMEPAD_STATE, connected: false }); + }); + + return () => { + disposed = true; + window.clearInterval(keepalive); + removeNativeTouchListener(); + void openNow.setAndroidNativeTouchControls?.({ enabled: false }); + virtualGamepadStateRef.current?.({ ...EMPTY_VIRTUAL_GAMEPAD_STATE, connected: false }); + }; + }, [ + androidTouchSettings.enabled, + androidTouchSettings.opacity, + androidTouchSettings.placement, + androidTouchSettings.size, + androidNativeTouchAvailable, + androidTouchSurfaceAvailable, + lowPowerTouchControls, + ]); + const androidNativeMouseCapture = androidTouchSettings.mouseCapture && androidPhysicalGamepads === 0; + const [androidMenuRevealSignal, setAndroidMenuRevealSignal] = useState(0); + const [androidTouchRevealSignal, setAndroidTouchRevealSignal] = useState(0); const mediaRecorderRef = useRef(null); const recordingIdRef = useRef(null); const recordingStartTimeRef = useRef(0); const recordingTimerRef = useRef(undefined); const thumbnailDataUrlRef = useRef(null); const recCarouselRef = useRef(null); + const touchMouseMoveRef = useRef(onTouchMouseMove); + const touchMouseButtonRef = useRef(onTouchMouseButton); + const touchMouseWheelRef = useRef(onTouchMouseWheel); + touchMouseMoveRef.current = onTouchMouseMove; + touchMouseButtonRef.current = onTouchMouseButton; + touchMouseWheelRef.current = onTouchMouseWheel; + const hasNativeMouseMoveHandler = Boolean(onTouchMouseMove); const recordingApiAvailable = - typeof window.openNow?.beginRecording === "function" && - typeof window.openNow?.sendRecordingChunk === "function" && - typeof window.openNow?.finishRecording === "function" && - typeof window.openNow?.abortRecording === "function" && - typeof window.openNow?.listRecordings === "function" && - typeof window.openNow?.deleteRecording === "function"; + typeof openNow?.beginRecording === "function" && + typeof openNow?.sendRecordingChunk === "function" && + typeof openNow?.finishRecording === "function" && + typeof openNow?.abortRecording === "function" && + typeof openNow?.listRecordings === "function" && + typeof openNow?.deleteRecording === "function"; + + const handleAndroidTouchSettingsChange = useCallback((next: AndroidTouchSettings) => { + onAndroidTouchControlsChange(normalizeAndroidTouchSettings(next)); + }, [onAndroidTouchControlsChange]); + + useEffect(() => { + if ( + !platformCapabilities.isAndroid || + !isStreaming || + isConnecting || + !androidNativeMouseCapture || + !hasNativeMouseMoveHandler + ) { + return; + } + + const unsubscribeMove = openNow.onNativeMouseMove((event) => { + const dx = Number(event.dx); + const dy = Number(event.dy); + if (!Number.isFinite(dx) || !Number.isFinite(dy) || (dx === 0 && dy === 0)) { + return; + } + touchMouseMoveRef.current?.({ dx, dy, timestampMs: event.timestampMs }); + }); + const unsubscribeButton = openNow.onNativeMouseButton((event) => { + const button = Number(event.button); + if (!Number.isFinite(button)) { + return; + } + touchMouseButtonRef.current?.({ + button, + pressed: Boolean(event.pressed), + timestampMs: event.timestampMs, + }); + }); + const unsubscribeWheel = openNow.onNativeMouseWheel((event) => { + const delta = Number(event.delta); + if (!Number.isFinite(delta) || delta === 0) { + return; + } + touchMouseWheelRef.current?.({ delta, timestampMs: event.timestampMs }); + }); + + void openNow.setNativePointerCapture(true); + + return () => { + unsubscribeMove(); + unsubscribeButton(); + unsubscribeWheel(); + void openNow.setNativePointerCapture(false); + }; + }, [androidNativeMouseCapture, hasNativeMouseMoveHandler, isConnecting, isStreaming]); + + useEffect(() => { + if (!platformCapabilities.isAndroid) { + return; + } + + const revealAndroidUi = (event: Event) => { + if (!isStreaming || isConnecting) { + return; + } + event.preventDefault(); + setAndroidMenuRevealSignal((value) => value + 1); + setAndroidTouchRevealSignal((value) => value + 1); + }; + + window.addEventListener("opennow:android-back", revealAndroidUi); + return () => window.removeEventListener("opennow:android-back", revealAndroidUi); + }, [isConnecting, isStreaming]); const microphoneModes = useMemo( () => [ @@ -747,11 +2060,17 @@ export function StreamView({ }, []); useEffect(() => { - const handleFullscreenChange = () => { - setIsFullscreen(!!document.fullscreenElement); + const syncFullscreenState = () => { + setIsFullscreen(Boolean(document.fullscreenElement) || document.body.dataset.androidFullscreen === "true"); + }; + syncFullscreenState(); + const observer = new MutationObserver(syncFullscreenState); + observer.observe(document.body, { attributes: true, attributeFilter: ["data-android-fullscreen"] }); + document.addEventListener("fullscreenchange", syncFullscreenState); + return () => { + observer.disconnect(); + document.removeEventListener("fullscreenchange", syncFullscreenState); }; - document.addEventListener("fullscreenchange", handleFullscreenChange); - return () => document.removeEventListener("fullscreenchange", handleFullscreenChange); }, []); useEffect(() => { @@ -1012,7 +2331,7 @@ export function StreamView({ return; } try { - const items = await window.openNow.listScreenshots(); + const items = await openNow.listScreenshots(); setScreenshots(items); } catch (error) { console.error("[StreamView] Failed to load screenshots:", error); @@ -1046,9 +2365,14 @@ export function StreamView({ throw new Error("Could not acquire 2D context"); } - context.drawImage(video, 0, 0, canvas.width, canvas.height); + const zoom = clampNumber(streamZoom, 1, 1.8); + const sourceWidth = Math.max(1, video.videoWidth / zoom); + const sourceHeight = Math.max(1, video.videoHeight / zoom); + const sourceX = (video.videoWidth - sourceWidth) / 2; + const sourceY = (video.videoHeight - sourceHeight) / 2; + context.drawImage(video, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, canvas.width, canvas.height); const dataUrl = canvas.toDataURL("image/png"); - const saved = await window.openNow.saveScreenshot({ dataUrl, gameTitle }); + const saved = await openNow.saveScreenshot({ dataUrl, gameTitle }); setScreenshots((prev) => [saved, ...prev.filter((item) => item.id !== saved.id)].slice(0, 60)); } catch (error) { console.error("[StreamView] Failed to capture screenshot:", error); @@ -1056,7 +2380,7 @@ export function StreamView({ } finally { setIsSavingScreenshot(false); } - }, [gameTitle, isSavingScreenshot, screenshotApiAvailable]); + }, [gameTitle, isSavingScreenshot, screenshotApiAvailable, streamZoom]); const scrollGallery = useCallback((direction: "left" | "right") => { const strip = galleryStripRef.current; @@ -1074,14 +2398,14 @@ export function StreamView({ if (!selectedScreenshot) return; try { - await window.openNow.deleteScreenshot({ id: selectedScreenshot.id }); + await openNow.deleteScreenshot({ id: selectedScreenshot.id }); setScreenshots((prev) => prev.filter((item) => item.id !== selectedScreenshot.id)); setSelectedScreenshotId(null); } catch (error) { console.error("[StreamView] Failed to delete screenshot:", error); setGalleryError("Unable to delete screenshot."); } - }, [screenshotApiAvailable, selectedScreenshot]); + }, [screenshotApiAvailable, screenshotExportAvailable, selectedScreenshot]); const handleSaveScreenshotAs = useCallback(async () => { setGalleryError(null); @@ -1091,19 +2415,24 @@ export function StreamView({ } if (!selectedScreenshot) return; + if (!screenshotExportAvailable) { + setGalleryError("Screenshot export is not available on this platform."); + return; + } + try { - await window.openNow.saveScreenshotAs({ id: selectedScreenshot.id }); + await openNow.saveScreenshotAs({ id: selectedScreenshot.id }); } catch (error) { console.error("[StreamView] Failed to save screenshot as:", error); setGalleryError("Unable to save screenshot."); } - }, [screenshotApiAvailable, selectedScreenshot]); + }, [screenshotApiAvailable, screenshotExportAvailable, selectedScreenshot]); const refreshRecordings = useCallback(async () => { setRecordingError(null); if (!recordingApiAvailable) return; try { - const items = await window.openNow.listRecordings(); + const items = await openNow.listRecordings(); setRecordings(items); } catch (error) { console.error("[StreamView] Failed to load recordings:", error); @@ -1115,7 +2444,7 @@ export function StreamView({ setRecordingError(null); if (!recordingApiAvailable) return; try { - await window.openNow.deleteRecording({ id }); + await openNow.deleteRecording({ id }); setRecordings((prev) => prev.filter((r) => r.id !== id)); } catch (error) { console.error("[StreamView] Failed to delete recording:", error); @@ -1186,7 +2515,7 @@ export function StreamView({ let recordingId: string; try { - const result = await window.openNow.beginRecording({ mimeType }); + const result = await openNow.beginRecording({ mimeType }); recordingId = result.recordingId; } catch (error) { console.error("[StreamView] Failed to begin recording:", error); @@ -1249,7 +2578,7 @@ export function StreamView({ void e.data.arrayBuffer().then((buf) => { const id = recordingIdRef.current; if (!id) return; - window.openNow.sendRecordingChunk({ recordingId: id, chunk: buf }).catch((err: unknown) => { + openNow.sendRecordingChunk({ recordingId: id, chunk: buf }).catch((err: unknown) => { console.error("[StreamView] Failed to send recording chunk:", err); }); }); @@ -1267,7 +2596,7 @@ export function StreamView({ if (!id) return; const durationMs = Date.now() - recordingStartTimeRef.current; - void window.openNow + void openNow .finishRecording({ recordingId: id, durationMs, @@ -1294,7 +2623,7 @@ export function StreamView({ setIsRecording(false); thumbnailDataUrlRef.current = null; if (id) { - window.openNow.abortRecording({ recordingId: id }).catch(() => undefined); + openNow.abortRecording({ recordingId: id }).catch(() => undefined); } setRecordingError("Recording encountered an error."); }; @@ -1313,7 +2642,7 @@ export function StreamView({ recorder.stop(); } if (id) { - window.openNow.abortRecording({ recordingId: id }).catch(() => undefined); + openNow.abortRecording({ recordingId: id }).catch(() => undefined); recordingIdRef.current = null; } audioCtxRef.current?.close().catch(() => undefined); @@ -1330,6 +2659,22 @@ export function StreamView({ } }, [videoRef]); + useEffect(() => { + if (isConnecting) { + setHasVideoFrame(false); + } + }, [isConnecting]); + + const markVideoFrameReady = useCallback(() => { + const video = localVideoRef.current; + if (!video) { + return; + } + if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA || (video.videoWidth > 0 && video.videoHeight > 0)) { + setHasVideoFrame(true); + } + }, []); + const setAudioRef = useCallback((element: HTMLAudioElement | null) => { localAudioRef.current = element; if (typeof audioRef === "function") { @@ -1398,6 +2743,15 @@ export function StreamView({ }, [onReleasePointerLock]); useEffect(() => { + if (!platformCapabilities.supportsKeyboardShortcuts && activeSidebarTab === "shortcuts") { + setActiveSidebarTab("preferences"); + } + }, [activeSidebarTab]); + + useEffect(() => { + if (!platformCapabilities.supportsKeyboardShortcuts) { + return; + } const screenshotShortcut = normalizeShortcut(shortcuts.screenshot); const recordingShortcut = normalizeShortcut(shortcuts.recording); const onKeyDown = (event: KeyboardEvent) => { @@ -1449,7 +2803,11 @@ export function StreamView({ playsInline muted tabIndex={0} - className="sv-video" + className={`sv-video${hasVideoFrame ? " sv-video--ready" : ""}`} + style={streamZoom > 1 ? { transform: `scale(${streamZoom})` } : undefined} + onLoadedData={markVideoFrameReady} + onCanPlay={markVideoFrameReady} + onResize={markVideoFrameReady} onClick={() => { if (localVideoRef.current && document.activeElement !== localVideoRef.current) { localVideoRef.current.focus(); @@ -1485,15 +2843,17 @@ export function StreamView({ > Preferences - + {platformCapabilities.supportsKeyboardShortcuts && ( + + )} {activeSidebarTab === "preferences" && ( @@ -1595,7 +2955,7 @@ export function StreamView({
Gallery - ScreensShot key: {shortcuts.screenshot} + {platformCapabilities.supportsKeyboardShortcuts ? `ScreensShot key: ${shortcuts.screenshot}` : "Capture screenshots from the sidebar"}
ScreensShot @@ -1643,7 +3003,7 @@ export function StreamView({
{screenshots.length === 0 && ( - No screenshots yet. Press {shortcuts.screenshot} to capture one. + {platformCapabilities.supportsKeyboardShortcuts ? `No screenshots yet. Press ${shortcuts.screenshot} to capture one.` : "No screenshots yet. Use Capture to save one."} )} {galleryError && {galleryError}}
@@ -1651,7 +3011,7 @@ export function StreamView({
Recordings - Record key: {shortcuts.recording} + {platformCapabilities.supportsKeyboardShortcuts ? `Record key: ${shortcuts.recording}` : "Start or stop recording from the sidebar"}
{usedMimeType && ( Codec: {usedMimeType} @@ -1674,7 +3034,7 @@ export function StreamView({ {recordingError} )} {recordings.length === 0 ? ( - No recordings yet. Press {shortcuts.recording} to record. + {platformCapabilities.supportsKeyboardShortcuts ? `No recordings yet. Press ${shortcuts.recording} to record.` : "No recordings yet. Use Start to record."} ) : (
+ )}