Introduce a Tauri based desktop version (#940)

This introduces a Tauri based desktop version of LiveSplit One. For now
very few changes have been done. It's mostly global hotkeys that work
now. It's unclear if Tauri is the long term solution for LiveSplit One,
but it gets us global hotkeys and auto splitting with very little
effort, so we'll use it until something better comes along.
This commit is contained in:
Christopher Serr
2024-07-15 21:14:45 +02:00
committed by GitHub
parent f6817019ab
commit ccb4bbd1b4
26 changed files with 5532 additions and 157 deletions
+164 -90
View File
@@ -4,115 +4,189 @@ on:
pull_request:
push:
branches:
- 'master'
- "master"
jobs:
build:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
label:
- Web
- Tauri on Windows
- Tauri on Linux
- Tauri on macOS ARM
include:
- label: Web
platform: web
dist_path: dist
os: ubuntu-latest
target: x86_64-unknown-linux-musl
binaryen: x86_64-linux
cargo_bin: /home/runner/.cargo/bin
- label: Tauri on Windows
platform: tauri
dist_path: src-tauri/target/dist
os: windows-latest
target: x86_64-pc-windows-msvc
binaryen: x86_64-windows
cargo_bin: C:/Users/runneradmin/.cargo/bin
- label: Tauri on Linux
platform: tauri
dist_path: src-tauri/target/dist
os: ubuntu-latest
target: x86_64-unknown-linux-musl
binaryen: x86_64-linux
cargo_bin: /home/runner/.cargo/bin
- label: Tauri on macOS ARM
platform: tauri
dist_path: src-tauri/target/dist
os: macos-latest
target: aarch64-apple-darwin
binaryen: arm64-macos
cargo_bin: /Users/runner/.cargo/bin
steps:
- name: Checkout commit
uses: actions/checkout@v4
with:
submodules: recursive
# This forces the entire history to be cloned, which is necessary for
# the changelog generation to work correctly.
fetch-depth: 0
- name: Checkout commit
uses: actions/checkout@v4
with:
submodules: recursive
# This forces the entire history to be cloned, which is necessary for
# the changelog generation to work correctly.
fetch-depth: 0
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: "lts/*"
- name: Install Rust
uses: hecrj/setup-rust-action@v2
with:
rust-version: nightly
components: rust-src
targets: wasm32-unknown-unknown
- name: Install Rust
uses: hecrj/setup-rust-action@v2
with:
rust-version: nightly
components: rust-src
targets: wasm32-unknown-unknown
- name: Download binaryen
if: github.repository == 'LiveSplit/LiveSplitOne' && github.ref == 'refs/heads/master'
uses: robinraju/[email protected]
with:
repository: "WebAssembly/binaryen"
latest: true
fileName: "binaryen-*-x86_64-linux.tar.gz"
out-file-path: "/home/runner/.cargo/bin"
- name: Download binaryen
if: github.repository == 'LiveSplit/LiveSplitOne' && github.ref == 'refs/heads/master'
uses: robinraju/[email protected]
with:
repository: "WebAssembly/binaryen"
latest: true
fileName: "binaryen-*-${{ matrix.binaryen }}.tar.gz"
out-file-path: ${{ matrix.cargo_bin }}
- name: Install binaryen
if: github.repository == 'LiveSplit/LiveSplitOne' && github.ref == 'refs/heads/master'
run: |
cd ~/.cargo/bin
tar -xzf binaryen-*-x86_64-linux.tar.gz
mv binaryen*/bin/wasm* .
- name: Install binaryen
if: github.repository == 'LiveSplit/LiveSplitOne' && github.ref == 'refs/heads/master'
run: |
cd ~/.cargo/bin
tar -xzf binaryen-*-${{ matrix.binaryen }}.tar.gz
mv binaryen*/bin/wasm* .
- name: Choose wasm-bindgen-cli version
run: echo "version=$(cd livesplit-core && cargo tree -i wasm-bindgen --features wasm-web --target wasm32-unknown-unknown --depth 0 | sed 's/.* v//g')" >> $GITHUB_OUTPUT
id: wasm-bindgen
- name: Choose wasm-bindgen-cli version
shell: bash
run: echo "version=$(cd livesplit-core && cargo tree -i wasm-bindgen --features wasm-web --target wasm32-unknown-unknown --depth 0 | sed 's/.* v//g')" >> $GITHUB_OUTPUT
id: wasm-bindgen
- name: Download wasm-bindgen-cli
uses: robinraju/[email protected]
with:
repository: "rustwasm/wasm-bindgen"
tag: ${{ steps.wasm-bindgen.outputs.version }}
fileName: "wasm-bindgen-${{ steps.wasm-bindgen.outputs.version }}-x86_64-unknown-linux-musl.tar.gz"
out-file-path: "/home/runner/.cargo/bin"
- name: Download wasm-bindgen-cli
uses: robinraju/[email protected]
with:
repository: "rustwasm/wasm-bindgen"
tag: ${{ steps.wasm-bindgen.outputs.version }}
fileName: "wasm-bindgen-${{ steps.wasm-bindgen.outputs.version }}-${{ matrix.target }}.tar.gz"
out-file-path: ${{ matrix.cargo_bin }}
- name: Install wasm-bindgen-cli
run: |
cd ~/.cargo/bin
tar -xzf wasm-bindgen-${{ steps.wasm-bindgen.outputs.version }}-x86_64-unknown-linux-musl.tar.gz
mv wasm-bindgen-${{ steps.wasm-bindgen.outputs.version }}-x86_64-unknown-linux-musl/wasm* .
- name: Install wasm-bindgen-cli
shell: bash
run: |
cd ${{ matrix.cargo_bin }}
tar -xzf wasm-bindgen-${{ steps.wasm-bindgen.outputs.version }}-${{ matrix.target }}.tar.gz
mv wasm-bindgen-${{ steps.wasm-bindgen.outputs.version }}-${{ matrix.target }}/wasm* .
- name: Install npm packages
run: npm ci -f
env:
DETECT_CHROMEDRIVER_VERSION: true
- name: Install npm packages
run: npm ci -f
env:
DETECT_CHROMEDRIVER_VERSION: true
- name: Build Core
run: npm run build:core:deploy
- name: Install native dependencies (Tauri)
if: matrix.platform == 'tauri' && matrix.os == 'ubuntu-latest'
run: |
sudo apt update
sudo apt install libwebkit2gtk-4.0-dev \
build-essential \
curl \
wget \
file \
libssl-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev
- name: Run eslint
run: npm run lint
- name: Build Core
run: npm run build:core:deploy
- name: Build Frontend
run: npm run publish
- name: Run eslint (Web)
if: matrix.platform == 'web'
run: npm run lint
- name: Cache screenshots
uses: actions/cache@v4
with:
path: test/screenshots
key: ${{ runner.os }}-screenshots-${{ hashFiles('test/rendering-test.js') }}
restore-keys: ${{ runner.os }}-screenshots-
- name: Build Frontend (Web)
if: matrix.platform == 'web'
run: npm run publish
- name: Run tests
run: |
echo "::add-matcher::.github/workflows/test-failures.json"
npm run test
- name: Build Frontend (Tauri)
if: matrix.platform == 'tauri'
run: npm run tauri:build-html
- name: Upload screenshots
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: Screenshots
path: test/screenshots
- name: Generate Icons (Tauri)
if: matrix.platform == 'tauri'
run: npm run tauri:icons
- name: Optimize
if: github.repository == 'LiveSplit/LiveSplitOne' && github.ref == 'refs/heads/master'
run: |
WASM_FILE=$(ls dist/*.wasm)
wasm-opt --all-features -O4 "$WASM_FILE" -o "$WASM_FILE"
- name: Cache screenshots (Web)
if: matrix.platform == 'web'
uses: actions/cache@v4
with:
path: test/screenshots
key: ${{ runner.os }}-screenshots-${{ hashFiles('test/rendering-test.js') }}
restore-keys: ${{ runner.os }}-screenshots-
- name: Add CNAME file
if: github.repository == 'LiveSplit/LiveSplitOne' && github.ref == 'refs/heads/master'
run: cp ./.github/workflows/CNAME ./dist/CNAME
- name: Run tests (Web)
if: matrix.platform == 'web'
run: |
echo "::add-matcher::.github/workflows/test-failures.json"
npm run test
- name: Deploy
if: github.repository == 'LiveSplit/LiveSplitOne' && github.ref == 'refs/heads/master'
uses: peaceiris/actions-gh-pages@v4
with:
deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }}
publish_branch: gh-pages
publish_dir: ./dist
force_orphan: true
- name: Upload screenshots (Web)
if: matrix.platform == 'web' && (success() || failure())
uses: actions/upload-artifact@v4
with:
name: Screenshots
path: test/screenshots
- name: Optimize
if: github.repository == 'LiveSplit/LiveSplitOne' && github.ref == 'refs/heads/master'
shell: bash
run: |
WASM_FILE=$(ls ${{ matrix.dist_path }}/*.wasm)
wasm-opt --all-features -O4 "$WASM_FILE" -o "$WASM_FILE"
- name: Build (Tauri)
if: matrix.platform == 'tauri'
run: npm run tauri:publish
- name: Add CNAME file (Web)
if: matrix.platform == 'web' && github.repository == 'LiveSplit/LiveSplitOne' && github.ref == 'refs/heads/master'
run: cp ./.github/workflows/CNAME ./${{ matrix.dist_path }}/CNAME
- name: Deploy (Web)
if: matrix.platform == 'web' && github.repository == 'LiveSplit/LiveSplitOne' && github.ref == 'refs/heads/master'
uses: peaceiris/actions-gh-pages@v4
with:
deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }}
publish_branch: gh-pages
publish_dir: ./${{ matrix.dist_path }}
force_orphan: true
+189
View File
@@ -33,6 +33,7 @@
},
"devDependencies": {
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.13",
"@tauri-apps/cli": "^1.6.0",
"@types/node": "^20.12.7",
"@types/selenium-webdriver": "^4.1.21",
"@typescript-eslint/eslint-plugin": "^7.7.0",
@@ -2897,6 +2898,194 @@
"string.prototype.matchall": "^4.0.6"
}
},
"node_modules/@tauri-apps/cli": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-1.6.0.tgz",
"integrity": "sha512-DBBpBl6GhTzm8ImMbKkfaZ4fDTykWrC7Q5OXP4XqD91recmDEn2LExuvuiiS3HYe7uP8Eb5B9NPHhqJb+Zo7qQ==",
"dev": true,
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "1.6.0",
"@tauri-apps/cli-darwin-x64": "1.6.0",
"@tauri-apps/cli-linux-arm-gnueabihf": "1.6.0",
"@tauri-apps/cli-linux-arm64-gnu": "1.6.0",
"@tauri-apps/cli-linux-arm64-musl": "1.6.0",
"@tauri-apps/cli-linux-x64-gnu": "1.6.0",
"@tauri-apps/cli-linux-x64-musl": "1.6.0",
"@tauri-apps/cli-win32-arm64-msvc": "1.6.0",
"@tauri-apps/cli-win32-ia32-msvc": "1.6.0",
"@tauri-apps/cli-win32-x64-msvc": "1.6.0"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-1.6.0.tgz",
"integrity": "sha512-SNRwUD9nqGxY47mbY1CGTt/jqyQOU7Ps7Mx/mpgahL0FVUDiCEY/5L9QfEPPhEgccgcelEVn7i6aQHIkHyUtCA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-1.6.0.tgz",
"integrity": "sha512-g2/uDR/eeH2arvuawA4WwaEOqv/7jDO/ZLNI3JlBjP5Pk8GGb3Kdy0ro1xQzF94mtk2mOnOXa4dMgAet4sUJ1A==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-1.6.0.tgz",
"integrity": "sha512-EVwf4oRkQyG8BpSrk0gqO7oA0sDM2MdNDtJpMfleYFEgCxLIOGZKNqaOW3M7U+0Y4qikmG3TtRK+ngc8Ymtrjg==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-1.6.0.tgz",
"integrity": "sha512-YdpY17cAySrhK9dX4BUVEmhAxE2o+6skIEFg8iN/xrDwRxhaNPI9I80YXPatUTX54Kx55T5++25VJG9+3iw83A==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.6.0.tgz",
"integrity": "sha512-4U628tuf2U8pMr4tIBJhEkrFwt+46dwhXrDlpdyWSZtnop5RJAVKHODm0KbWns4xGKfTW1F3r6sSv+2ZxLcISA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-1.6.0.tgz",
"integrity": "sha512-AKRzp76fVUaJyXj5KRJT9bJyhwZyUnRQU0RqIRqOtZCT5yr6qGP8rjtQ7YhCIzWrseBlOllc3Qvbgw3Yl0VQcA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-1.6.0.tgz",
"integrity": "sha512-0edIdq6aMBTaRMIXddHfyAFL361JqulLLd2Wi2aoOie7DkQ2MYh6gv3hA7NB9gqFwNIGE+xtJ4BkXIP2tSGPlg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-1.6.0.tgz",
"integrity": "sha512-QwWpWk4ubcwJ1rljsRAmINgB2AwkyzZhpYbalA+MmzyYMREcdXWGkyixWbRZgqc6fEWEBmq5UG73qz5eBJiIKg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-1.6.0.tgz",
"integrity": "sha512-Vtw0yxO9+aEFuhuxQ57ALG43tjECopRimRuKGbtZYDCriB/ty5TrT3QWMdy0dxBkpDTu3Rqsz30sbDzw6tlP3Q==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-1.6.0.tgz",
"integrity": "sha512-h54FHOvGi7+LIfRchzgZYSCHB1HDlP599vWXQQJ/XnwJY+6Rwr2E5bOe/EhqoG8rbGkfK0xX3KPAvXPbUlmggg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@testim/chrome-version": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@testim/chrome-version/-/chrome-version-1.1.4.tgz",
+6 -1
View File
@@ -13,7 +13,11 @@
"publish": "webpack --mode production",
"serve": "webpack serve",
"start": "webpack serve --open",
"test": "mocha --exit test/**/*.js"
"test": "mocha --exit test/**/*.js",
"tauri:icons": "tauri icon src/assets/icon.png",
"tauri:build-html": "webpack --mode production --env TAURI=true",
"tauri:watch": "tauri dev",
"tauri:publish": "tauri build"
},
"author": "",
"license": "ISC",
@@ -42,6 +46,7 @@
},
"devDependencies": {
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.13",
"@tauri-apps/cli": "^1.6.0",
"@types/node": "^20.12.7",
"@types/selenium-webdriver": "^4.1.21",
"@typescript-eslint/eslint-plugin": "^7.7.0",
+9
View File
@@ -0,0 +1,9 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
/icons
+4636
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "livesplit-one"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "1", features = [] }
[dependencies]
tauri = { version = "1", features = [ "http-all"] }
serde = { version = "1" }
serde_derive = { version = "1" }
serde_json = "1"
livesplit-core = { path = "../livesplit-core" }
[features]
# This feature is used for production builds or when a dev server is not specified, DO NOT REMOVE!!
custom-protocol = ["tauri/custom-protocol"]
[profile.release]
lto = true
panic = "abort"
codegen-units = 1
strip = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+225
View File
@@ -0,0 +1,225 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::{
borrow::Cow,
future::Future,
str::FromStr,
sync::{Arc, RwLock},
};
use livesplit_core::{
event::{CommandSink, Event, Result},
hotkey::KeyCode,
networking::server_protocol::Command,
HotkeyConfig, HotkeySystem, TimeSpan, TimingMethod,
};
use tauri::{Manager, Window};
struct State {
hotkey_system: RwLock<Option<HotkeySystem<TauriCommandSink>>>,
window: RwLock<Option<Window>>,
}
#[tauri::command]
fn set_hotkey_config(state: tauri::State<'_, State>, config: HotkeyConfig) -> bool {
if let Some(hotkey_system) = &mut *state.hotkey_system.write().unwrap() {
hotkey_system.set_config(config).is_ok()
} else {
false
}
}
#[tauri::command]
fn set_hotkey_activation(state: tauri::State<'_, State>, active: bool) -> bool {
if let Some(hotkey_system) = &mut *state.hotkey_system.write().unwrap() {
if active {
hotkey_system.activate()
} else {
hotkey_system.deactivate()
}
.is_ok()
} else {
false
}
}
#[tauri::command]
fn get_hotkey_config(state: tauri::State<'_, State>) -> HotkeyConfig {
if let Some(hotkey_system) = &*state.hotkey_system.read().unwrap() {
hotkey_system.config()
} else {
HotkeyConfig::default()
}
}
#[tauri::command]
fn resolve_hotkey(state: tauri::State<'_, State>, key_code: String) -> Cow<'static, str> {
if let Some(hotkey_system) = &*state.hotkey_system.read().unwrap() {
if let Ok(key_code) = KeyCode::from_str(&key_code) {
return hotkey_system.resolve(key_code);
}
}
key_code.into()
}
#[derive(Clone)]
struct TauriCommandSink(Arc<RwLock<Option<Window>>>);
impl TauriCommandSink {
fn send(&self, command: Command) {
self.0
.read()
.unwrap()
.as_ref()
.unwrap()
.emit("command", command)
.unwrap();
}
}
impl CommandSink for TauriCommandSink {
fn start(&self) -> impl Future<Output = Result> + 'static {
self.send(Command::Start);
async { Ok(Event::Unknown) }
}
fn split(&self) -> impl Future<Output = Result> + 'static {
self.send(Command::Split);
async { Ok(Event::Unknown) }
}
fn split_or_start(&self) -> impl Future<Output = Result> + 'static {
self.send(Command::SplitOrStart);
async { Ok(Event::Unknown) }
}
fn reset(&self, save_attempt: Option<bool>) -> impl Future<Output = Result> + 'static {
self.send(Command::Reset { save_attempt });
async { Ok(Event::Unknown) }
}
fn undo_split(&self) -> impl Future<Output = Result> + 'static {
self.send(Command::UndoSplit);
async { Ok(Event::Unknown) }
}
fn skip_split(&self) -> impl Future<Output = Result> + 'static {
self.send(Command::SkipSplit);
async { Ok(Event::Unknown) }
}
fn toggle_pause_or_start(&self) -> impl Future<Output = Result> + 'static {
self.send(Command::TogglePauseOrStart);
async { Ok(Event::Unknown) }
}
fn pause(&self) -> impl Future<Output = Result> + 'static {
self.send(Command::Pause);
async { Ok(Event::Unknown) }
}
fn resume(&self) -> impl Future<Output = Result> + 'static {
self.send(Command::Resume);
async { Ok(Event::Unknown) }
}
fn undo_all_pauses(&self) -> impl Future<Output = Result> + 'static {
self.send(Command::UndoAllPauses);
async { Ok(Event::Unknown) }
}
fn switch_to_previous_comparison(&self) -> impl Future<Output = Result> + 'static {
self.send(Command::SwitchToPreviousComparison);
async { Ok(Event::Unknown) }
}
fn switch_to_next_comparison(&self) -> impl Future<Output = Result> + 'static {
self.send(Command::SwitchToNextComparison);
async { Ok(Event::Unknown) }
}
fn set_current_comparison(
&self,
comparison: Cow<'_, str>,
) -> impl Future<Output = Result> + 'static {
self.send(Command::SetCurrentComparison { comparison });
async { Ok(Event::Unknown) }
}
fn toggle_timing_method(&self) -> impl Future<Output = Result> + 'static {
self.send(Command::ToggleTimingMethod);
async { Ok(Event::Unknown) }
}
fn set_current_timing_method(
&self,
method: TimingMethod,
) -> impl Future<Output = Result> + 'static {
self.send(Command::SetCurrentTimingMethod {
timing_method: method,
});
async { Ok(Event::Unknown) }
}
fn initialize_game_time(&self) -> impl Future<Output = Result> + 'static {
self.send(Command::InitializeGameTime);
async { Ok(Event::Unknown) }
}
fn set_game_time(&self, time: TimeSpan) -> impl Future<Output = Result> + 'static {
self.send(Command::SetGameTime { time });
async { Ok(Event::Unknown) }
}
fn pause_game_time(&self) -> impl Future<Output = Result> + 'static {
self.send(Command::PauseGameTime);
async { Ok(Event::Unknown) }
}
fn resume_game_time(&self) -> impl Future<Output = Result> + 'static {
self.send(Command::ResumeGameTime);
async { Ok(Event::Unknown) }
}
fn set_loading_times(&self, time: TimeSpan) -> impl Future<Output = Result> + 'static {
self.send(Command::SetLoadingTimes { time });
async { Ok(Event::Unknown) }
}
fn set_custom_variable(
&self,
key: Cow<'_, str>,
value: Cow<'_, str>,
) -> impl Future<Output = Result> + 'static {
self.send(Command::SetCustomVariable { key, value });
async { Ok(Event::Unknown) }
}
}
fn main() {
let sink = TauriCommandSink(Arc::new(RwLock::new(None)));
let hotkey_system = RwLock::new(HotkeySystem::new(sink.clone()).ok());
tauri::Builder::default()
.manage(State {
hotkey_system,
window: RwLock::new(None),
})
.setup(move |app| {
let main_window = app.windows().values().next().unwrap().clone();
app.state::<State>()
.window
.write()
.unwrap()
.replace(main_window.clone());
*sink.0.write().unwrap() = Some(main_window);
Ok(())
})
.invoke_handler(tauri::generate_handler![
set_hotkey_config,
set_hotkey_activation,
get_hotkey_config,
resolve_hotkey,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+43
View File
@@ -0,0 +1,43 @@
{
"build": {
"devPath": "target/dist",
"distDir": "target/dist",
"withGlobalTauri": true
},
"package": {
"productName": "LiveSplit One",
"version": "0.1.0"
},
"tauri": {
"allowlist": {
"all": false,
"http": {
"all": true,
"request": true,
"scope": ["https://www.speedrun.com/static/*"]
}
},
"windows": [
{
"title": "LiveSplit One",
"width": 850,
"height": 750
}
],
"security": {
"csp": null
},
"bundle": {
"active": true,
"targets": "all",
"identifier": "org.livesplit.one",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/[email protected]",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

+1 -1
View File
@@ -18,7 +18,7 @@ if (typeof Symbol.asyncDispose !== "symbol") {
});
}
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
if (process.env.NODE_ENV === "production" && window.__TAURI__ == null && "serviceWorker" in navigator) {
navigator.serviceWorker.register("/service-worker.js");
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { WebRenderer } from "../livesplit-core/livesplit_core";
import AutoRefresh from "../util/AutoRefresh";
import { UrlCache } from "../util/UrlCache";
import "../css/Layout.scss";
import { GeneralSettings } from "../ui/SettingsEditor";
import { GeneralSettings } from "../ui/MainSettings";
export interface Props {
getState: () => LayoutStateRef,
+15
View File
@@ -0,0 +1,15 @@
export async function corsBustingFetch(url: string, signal?: AbortSignal): Promise<ArrayBuffer> {
if (window.__TAURI__ != null) {
const response = await window.__TAURI__.http.fetch(
url,
{ responseType: 3 },
);
if (signal != null) {
signal.throwIfAborted();
}
return new Uint8Array(response.data);
} else {
const response = await fetch(url, { signal });
return response.arrayBuffer();
}
}
+85
View File
@@ -0,0 +1,85 @@
import { CommandSinkRef, HotkeyConfig, HotkeySystem } from "../livesplit-core";
import { expect } from "../util/OptionUtil";
export interface HotkeyImplementation {
config(): Promise<HotkeyConfig> | HotkeyConfig,
setConfig(config: HotkeyConfig): void,
activate(): void,
deactivate(): void,
resolve(keyCode: string): Promise<string> | string,
}
class GlobalHotkeys implements HotkeyImplementation {
constructor(private hotkeySystem?: HotkeySystem) { }
public async config(): Promise<HotkeyConfig> {
return expect(HotkeyConfig.parseJson(
await window.__TAURI__!.tauri.invoke("get_hotkey_config"),
), "Couldn't parse the hotkey config.");
}
public setConfig(config: HotkeyConfig): void {
window.__TAURI__!.tauri.invoke("set_hotkey_config", { config: config.asJson() });
if (this.hotkeySystem != null) {
this.hotkeySystem.setConfig(config);
} else {
config[Symbol.dispose]();
}
}
setConfigJson(configJson: unknown): void {
window.__TAURI__!.tauri.invoke("set_hotkey_config", { config: configJson });
if (this.hotkeySystem != null) {
const config = HotkeyConfig.parseJson(configJson);
if (config != null) {
this.hotkeySystem.setConfig(config);
}
}
}
public activate(): void {
window.__TAURI__!.tauri.invoke("set_hotkey_activation", { active: true });
this.hotkeySystem?.activate();
}
public deactivate(): void {
window.__TAURI__!.tauri.invoke("set_hotkey_activation", { active: false });
this.hotkeySystem?.deactivate();
}
public resolve(keyCode: string): Promise<string> {
return window.__TAURI__!.tauri.invoke("resolve_hotkey", { keyCode });
}
}
export function createHotkeys(commandSink: CommandSinkRef, configJson: unknown): HotkeyImplementation {
let hotkeySystem: HotkeySystem | null = null;
const tauri = window.__TAURI__ != null;
if (!tauri || navigator.platform === "Win32") {
try {
const config = HotkeyConfig.parseJson(configJson);
if (config !== null) {
hotkeySystem = HotkeySystem.withConfig(commandSink, config);
}
} catch (_) { /* Looks like the storage has no valid data */ }
if (hotkeySystem == null) {
hotkeySystem = expect(
HotkeySystem.new(commandSink),
"Couldn't initialize the hotkeys",
);
}
}
if (tauri) {
const globalHotkeys = new GlobalHotkeys(hotkeySystem ?? undefined);
if (configJson != null) {
globalHotkeys.setConfigJson(configJson);
}
return globalHotkeys;
} else {
return hotkeySystem!;
}
}
+4 -2
View File
@@ -1,7 +1,7 @@
import { openDB, IDBPDatabase } from "idb";
import { Option, assert } from "../util/OptionUtil";
import { RunRef, Run, TimingMethod } from "../livesplit-core";
import { GeneralSettings } from "../ui/SettingsEditor";
import { GeneralSettings } from "../ui/MainSettings";
import { FRAME_RATE_AUTOMATIC } from "../util/FrameRate";
export type HotkeyConfigSettings = unknown;
@@ -247,9 +247,11 @@ export async function loadGeneralSettings(): Promise<GeneralSettings> {
const generalSettings = await db.get("settings", "generalSettings") ?? {};
const isTauri = window.__TAURI__ != null;
return {
frameRate: generalSettings.frameRate ?? FRAME_RATE_AUTOMATIC,
showControlButtons: generalSettings.showControlButtons ?? true,
showControlButtons: generalSettings.showControlButtons ?? !isTauri,
showManualGameTime: generalSettings.showManualGameTime ?? false,
saveOnReset: generalSettings.saveOnReset ?? false,
speedrunComIntegration: generalSettings.speedrunComIntegration ?? true,
+46
View File
@@ -0,0 +1,46 @@
declare var __TAURI__: GlobalTauri | undefined;
declare interface GlobalTauri {
tauri: TauriModule;
event: TauriEventModule;
notification: TauriNotificationModule;
http: TauriHttpModule;
}
declare interface TauriModule {
invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T>;
}
declare interface TauriEventModule {
listen(eventName: string, callback: (event: TauriEvent) => void): Promise<ListenHandle>;
}
declare interface TauriNotificationModule {
isPermissionGranted(): Promise<boolean>;
requestPermission(): Promise<string>;
sendNotification(notification: TauriNotification): void;
}
declare interface TauriNotification {
title: string;
body: string;
}
declare interface TauriHttpModule {
fetch(url: string, options: { responseType: 3 }): Promise<TauriResponse<Array<number>>>;
}
declare interface TauriResponse<T> {
data: T,
}
declare interface TauriHttpOptions {
responseType: number;
}
declare interface TauriEvent {
event: string;
payload: unknown;
}
declare interface ListenHandle { }
+22 -6
View File
@@ -4,7 +4,7 @@ import { hotkeySystem } from "./LiveSplit";
import "../css/HotkeyButton.scss";
function resolveKey(keyCode: string): string {
function resolveKey(keyCode: string): Promise<string> | string {
return expect(hotkeySystem, "The Hotkey System should always be initialized.").resolve(keyCode);
}
@@ -16,6 +16,7 @@ export interface Props {
export interface State {
listener: Option<EventListenerObject>,
intervalHandle: Option<number>,
resolvedKey: Option<string>,
}
export default class HotkeyButton extends React.Component<Props, State> {
@@ -25,19 +26,34 @@ export default class HotkeyButton extends React.Component<Props, State> {
this.state = {
listener: null,
intervalHandle: null,
resolvedKey: null,
};
this.updateResolvedKey(props.value);
}
public componentDidUpdate(previousProps: Props) {
if (previousProps.value !== this.props.value) {
this.updateResolvedKey(this.props.value);
}
}
public render() {
const value = this.props.value;
let buttonText = null;
private async updateResolvedKey(value: Option<string>): Promise<void> {
let resolvedKey = "";
if (value != null) {
const matches = value.match(/(.+)\+\s*(.+)$/);
if (matches != null) {
buttonText = `${matches[1]}+ ${resolveKey(matches[2])}`;
resolvedKey = `${matches[1]}+ ${await resolveKey(matches[2])}`;
} else {
buttonText = resolveKey(value);
resolvedKey = await resolveKey(value);
}
}
this.setState({ resolvedKey });
}
public render() {
let buttonText: Option<string> | JSX.Element = null;
if (this.props.value != null) {
buttonText = this.state.resolvedKey;
} else if (this.state.listener != null) {
buttonText = <i className="fa fa-circle" aria-hidden="true" />;
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { SettingsComponent } from "./Settings";
import { UrlCache } from "../util/UrlCache";
import Layout from "../layout/Layout";
import { WebRenderer } from "../livesplit-core/livesplit_core";
import { GeneralSettings } from "./SettingsEditor";
import { GeneralSettings } from "./MainSettings";
import { LSOCommandSink } from "./LSOCommandSink";
import "../css/LayoutEditor.scss";
+2 -2
View File
@@ -3,7 +3,7 @@ import { Layout, LayoutStateRefMut, TimerPhase, TimingMethod } from "../livespli
import { TimerView } from "./TimerView";
import { UrlCache } from "../util/UrlCache";
import { WebRenderer } from "../livesplit-core/livesplit_core";
import { GeneralSettings } from "./SettingsEditor";
import { GeneralSettings } from "./MainSettings";
import { LiveSplitServer } from "../api/LiveSplitServer";
import { Option } from "../util/OptionUtil";
import { LSOCommandSink } from "./LSOCommandSink";
@@ -42,7 +42,7 @@ interface Callbacks {
openLayoutEditor(): void,
openLayoutView(): void,
openSplitsView(): void,
openSettingsEditor(): void,
openMainSettings(): void,
openTimerView(): void,
renderViewWithSidebar(renderedView: JSX.Element, sidebarContent: JSX.Element): JSX.Element,
saveLayout(): void,
+24 -31
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import Sidebar from "react-sidebar";
import {
HotkeySystem, Layout, LayoutEditor, Run, RunEditor, Segment,
Layout, LayoutEditor, Run, RunEditor, Segment,
Timer, HotkeyConfig, LayoutState, LayoutStateJson,
TimingMethod, TimerPhase,
Event,
@@ -11,7 +11,7 @@ import { Option, assertNull, expect, maybeDisposeAndThen, panic } from "../util/
import * as SplitsIO from "../util/SplitsIO";
import { LayoutEditor as LayoutEditorComponent } from "./LayoutEditor";
import { RunEditor as RunEditorComponent } from "./RunEditor";
import { GeneralSettings, SettingsEditor as SettingsEditorComponent } from "./SettingsEditor";
import { GeneralSettings, MainSettings as SettingsEditorComponent } from "./MainSettings";
import { TimerView } from "./TimerView";
import { About } from "./About";
import { SplitsSelection, EditingInfo } from "./SplitsSelection";
@@ -19,10 +19,11 @@ import { LayoutView } from "./LayoutView";
import { ToastContainer, toast } from "react-toastify";
import * as Storage from "../storage";
import { UrlCache } from "../util/UrlCache";
import { WebRenderer } from "../livesplit-core/livesplit_core";
import { ServerProtocol, WebRenderer } from "../livesplit-core/livesplit_core";
import { LiveSplitServer } from "../api/LiveSplitServer";
import { LSOCommandSink } from "./LSOCommandSink";
import DialogContainer from "./Dialog";
import { createHotkeys, HotkeyImplementation } from "../platform/Hotkeys";
import variables from "../css/variables.scss";
@@ -39,7 +40,7 @@ export enum MenuKind {
RunEditor,
Layout,
LayoutEditor,
SettingsEditor,
MainSettings,
About,
}
@@ -59,7 +60,7 @@ type Menu =
{ kind: MenuKind.RunEditor, editor: RunEditor, splitsKey?: number } |
{ kind: MenuKind.Layout } |
{ kind: MenuKind.LayoutEditor, editor: LayoutEditor } |
{ kind: MenuKind.SettingsEditor, config: HotkeyConfig } |
{ kind: MenuKind.MainSettings, config: HotkeyConfig } |
{ kind: MenuKind.About };
export interface Props {
@@ -75,7 +76,7 @@ export interface Props {
}
export interface State {
hotkeySystem: HotkeySystem,
hotkeySystem: HotkeyImplementation,
isBrowserSource: boolean,
isDesktop: boolean,
layout: Layout,
@@ -104,7 +105,7 @@ export interface State {
layoutModified: boolean,
}
export let hotkeySystem: Option<HotkeySystem> = null;
export let hotkeySystem: Option<HotkeyImplementation> = null;
export class LiveSplit extends React.Component<Props, State> {
public static async loadStoredData() {
@@ -150,21 +151,7 @@ export class LiveSplit extends React.Component<Props, State> {
this,
);
const hotkeys = props.hotkeys;
try {
if (hotkeys !== undefined) {
const config = HotkeyConfig.parseJson(hotkeys);
if (config !== null) {
hotkeySystem = HotkeySystem.withConfig(commandSink.getCommandSink(), config);
}
}
} catch (_) { /* Looks like the storage has no valid data */ }
if (hotkeySystem == null) {
hotkeySystem = expect(
HotkeySystem.new(commandSink.getCommandSink()),
"Couldn't initialize the hotkeys",
);
}
hotkeySystem = createHotkeys(commandSink.getCommandSink(), props.hotkeys);
if (window.location.hash.indexOf("#/splits-io/") === 0) {
const loadingRun = Run.new();
@@ -236,6 +223,13 @@ export class LiveSplit extends React.Component<Props, State> {
layoutModified: false,
};
if (window.__TAURI__ != null) {
window.__TAURI__.event.listen("command", (event) => {
const payloadString = JSON.stringify(event.payload);
ServerProtocol.handleCommand(payloadString, commandSink.getCommandSink().ptr);
});
}
this.updateBadge();
this.mediaQueryChanged = this.mediaQueryChanged.bind(this);
@@ -308,7 +302,6 @@ export class LiveSplit extends React.Component<Props, State> {
this.state.commandSink[Symbol.dispose]();
this.state.layout[Symbol.dispose]();
this.state.layoutState[Symbol.dispose]();
this.state.hotkeySystem?.[Symbol.dispose]();
// This is bound in the constructor
// eslint-disable-next-line @typescript-eslint/unbound-method
@@ -345,7 +338,7 @@ export class LiveSplit extends React.Component<Props, State> {
renderer={this.state.renderer}
callbacks={this}
/>;
} else if (this.state.menu.kind === MenuKind.SettingsEditor) {
} else if (this.state.menu.kind === MenuKind.MainSettings) {
view = <SettingsEditorComponent
generalSettings={this.state.generalSettings}
hotkeyConfig={this.state.menu.config}
@@ -635,24 +628,24 @@ export class LiveSplit extends React.Component<Props, State> {
this.openTimerView();
}
public openSettingsEditor() {
public async openMainSettings() {
this.changeMenu({
kind: MenuKind.SettingsEditor,
config: this.state.hotkeySystem.config(),
kind: MenuKind.MainSettings,
config: await this.state.hotkeySystem.config(),
});
}
public async closeSettingsEditor(save: boolean, generalSettings: GeneralSettings) {
public async closeMainSettings(save: boolean, generalSettings: GeneralSettings) {
const menu = this.state.menu;
if (menu.kind !== MenuKind.SettingsEditor) {
if (menu.kind !== MenuKind.MainSettings) {
panic("No Settings Editor to close.");
}
if (save) {
try {
const hotkeys = menu.config.asJson();
await Storage.storeHotkeys(hotkeys);
const config = menu.config.asJson();
await Storage.storeHotkeys(config);
} catch {
toast.error("Failed to save the hotkey settings.");
}
@@ -38,13 +38,13 @@ export interface State {
interface Callbacks {
renderViewWithSidebar(renderedView: JSX.Element, sidebarContent: JSX.Element): JSX.Element,
closeSettingsEditor(save: boolean, newGeneralSettings: GeneralSettings): void,
closeMainSettings(save: boolean, newGeneralSettings: GeneralSettings): void,
onServerConnectionOpened(serverConnection: LiveSplitServer): void,
onServerConnectionClosed(): void,
forceUpdate(): void,
}
export class SettingsEditor extends React.Component<Props, State> {
export class MainSettings extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
@@ -257,13 +257,13 @@ export class SettingsEditor extends React.Component<Props, State> {
<div className="small">
<button
className="toggle-left"
onClick={(_) => this.props.callbacks.closeSettingsEditor(true, this.state.generalSettings)}
onClick={(_) => this.props.callbacks.closeMainSettings(true, this.state.generalSettings)}
>
<i className="fa fa-check" aria-hidden="true" /> OK
</button>
<button
className="toggle-right"
onClick={(_) => this.props.callbacks.closeSettingsEditor(false, this.state.generalSettings)}
onClick={(_) => this.props.callbacks.closeMainSettings(false, this.state.generalSettings)}
>
<i className="fa fa-times" aria-hidden="true" /> Cancel
</button>
+4 -5
View File
@@ -23,8 +23,9 @@ import {
} from "./Settings";
import { renderMarkdown, replaceFlag } from "../util/Markdown";
import { UrlCache } from "../util/UrlCache";
import { GeneralSettings } from "./SettingsEditor";
import { GeneralSettings } from "./MainSettings";
import { showDialog } from "./Dialog";
import { corsBustingFetch } from "../platform/CORS";
import "../css/RunEditor.scss";
@@ -2020,8 +2021,7 @@ export class RunEditor extends React.Component<Props, State> {
if (game !== undefined) {
const uri = game.assets["cover-medium"].uri;
if (uri.startsWith("https://") && uri !== "https://www.speedrun.com/images/blankcover.png") {
const response = await fetch(uri, { signal });
const buffer = await response.arrayBuffer();
const buffer = await corsBustingFetch(uri, signal);
if (this.props.editor.ptr === 0) {
return;
}
@@ -2050,8 +2050,7 @@ export class RunEditor extends React.Component<Props, State> {
if (game !== undefined) {
const uri = game.assets.icon.uri;
if (uri.startsWith("https://") && uri !== "https://www.speedrun.com/images/1st.png") {
const response = await fetch(uri, { signal });
const buffer = await response.arrayBuffer();
const buffer = await corsBustingFetch(uri, signal);
if (this.props.editor.ptr === 0) {
return;
}
+2 -2
View File
@@ -10,7 +10,7 @@ import { openFileAsArrayBuffer, exportFile, convertFileToArrayBuffer, FILE_EXT_S
import { Option, bug, maybeDisposeAndThen } from "../util/OptionUtil";
import DragUpload from "./DragUpload";
import { ContextMenuTrigger, ContextMenu, MenuItem } from "react-contextmenu";
import { GeneralSettings } from "./SettingsEditor";
import { GeneralSettings } from "./MainSettings";
import { LSOCommandSink } from "./LSOCommandSink";
import { showDialog } from "./Dialog";
@@ -187,7 +187,7 @@ export class SplitsSelection extends React.Component<Props, State> {
<hr />
<button onClick={(_) => {
if (this.props.commandSink.currentPhase() !== TimerPhase.NotRunning) {
toast.error("You can't edit your run while the timer is running.");
toast.error("You can't edit your splits while the timer is running.");
return;
}
const run = this.props.commandSink.getRun().clone();
+3 -3
View File
@@ -6,7 +6,7 @@ import DragUpload from "./DragUpload";
import Layout from "../layout/Layout";
import { UrlCache } from "../util/UrlCache";
import { WebRenderer } from "../livesplit-core/livesplit_core";
import { GeneralSettings } from "./SettingsEditor";
import { GeneralSettings } from "./MainSettings";
import { LiveSplitServer } from "../api/LiveSplitServer";
import { LSOCommandSink } from "./LSOCommandSink";
@@ -47,7 +47,7 @@ interface Callbacks {
openAboutView(): void,
openLayoutView(): void,
openSplitsView(): void,
openSettingsEditor(): void,
openMainSettings(): void,
renderViewWithSidebar(renderedView: JSX.Element, sidebarContent: JSX.Element): JSX.Element,
onServerConnectionClosed(): void,
onServerConnectionOpened(serverConnection: LiveSplitServer): void,
@@ -262,7 +262,7 @@ export class TimerView extends React.Component<Props, State> {
</button>
</div>
<hr />
<button onClick={() => this.props.callbacks.openSettingsEditor()}>
<button onClick={() => this.props.callbacks.openMainSettings()}>
<i className="fa fa-cog" aria-hidden="true" /> Settings
</button>
<button onClick={(_) => this.props.callbacks.openAboutView()}>
+14 -7
View File
@@ -83,7 +83,11 @@ export default async (env, argv) => {
const basePath = path.dirname(fileURLToPath(import.meta.url));
const isProduction = argv.mode === "production";
const distPath = path.join(basePath, "dist");
const isTauri = env.TAURI === "true";
const distPath = path.join(...[
basePath,
...(isTauri ? ["src-tauri", "target", "dist"] : ["dist"]),
]);
return {
entry: {
@@ -107,8 +111,11 @@ export default async (env, argv) => {
},
plugins: [
...(isProduction ? [new CleanWebpackPlugin()] : []),
new FaviconsWebpackPlugin({
...(isProduction ? [new CleanWebpackPlugin({
protectWebpackAssets: false,
cleanAfterEveryBuildPatterns: ['*.LICENSE.txt'],
})] : []),
...(isTauri ? [] : [new FaviconsWebpackPlugin({
logo: path.resolve("src/assets/icon.svg"),
inject: true,
logoMaskable: path.resolve("src/assets/maskable.svg"),
@@ -133,7 +140,7 @@ export default async (env, argv) => {
},
start_url: "/",
},
}),
})]),
new HtmlWebpackPlugin({
template: "./src/index.html",
}),
@@ -147,7 +154,7 @@ export default async (env, argv) => {
new HtmlInlineScriptPlugin({
scriptMatchPattern: ['^bundle.js$'],
}),
new WorkboxPlugin.GenerateSW({
...(isTauri ? [] : [new WorkboxPlugin.GenerateSW({
clientsClaim: true,
skipWaiting: true,
maximumFileSizeToCacheInBytes: 100 * 1024 * 1024,
@@ -163,9 +170,9 @@ export default async (env, argv) => {
},
handler: "CacheFirst",
}],
})
})])
] : []),
...(!isProduction ? [new ReactRefreshWebpackPlugin()] : [])
...(!isProduction ? [new ReactRefreshWebpackPlugin()] : []),
],
module: {