Compare commits

...

53 Commits

Author SHA1 Message Date
Shidorien 74b91ee277 enhance BsmDropdownButton with maxHeight calculation and item height tracking 2026-01-30 21:07:28 +01:00
Shidorien 756cccf85c Handling of right-click outside the menu to close the menu 2026-01-30 20:38:02 +01:00
Iluhadesu 1591e32b4f Add v1.42.1 to bs-versions.json 2026-01-29 14:13:25 +00:00
frto027 57ca031e70 [feat-994] Add support for alternative mod repos (#995)
* [feat] Add support for alternative mod repos

* Fix lint errors

* Simplify code + Fix potential issues

---------

Co-authored-by: Zagrios <40181755+Zagrios@users.noreply.github.com>
2026-01-26 16:06:44 +01:00
Vyacheslav 49ef8203c8 fix: use electron.net for beatmods.com requests to avoid Cloudflare timeouts (#986)
* [fix] Use electron.net for beatmods.com requests

* [fix] Route beatmods.com file downloads through electron.net

* Add error handlers

* Refactor error handling in RequestService to ensure proper completion of file write operations

* fix lint errors

---------

Co-authored-by: Zagrios <40181755+Zagrios@users.noreply.github.com>
2026-01-23 13:48:12 +01:00
Zagrios d9a68e91ca [chore] update patreons.json (#991) 2026-01-21 04:15:23 +01:00
Zagrios 2a4e775cec [chore] update patreons.json (#987) 2026-01-10 05:08:05 +01:00
Zagrios 58529a2ea2 [chore] update patreons.json (#985) 2026-01-07 09:38:22 +01:00
Zagrios ff028c77a1 update workflow 2026-01-07 09:36:37 +01:00
Zagrios 8edfea66f2 update deps 2026-01-02 17:46:04 +01:00
Zagrios 378fa1150e [fix] Follow script redirect to get the JSON (#981) 2026-01-02 16:12:47 +01:00
silentrald 92a1232f85 [fix] handle launch option string properly (#895)
* [fix] refactored launch functions to handle wrapper executables

* [fix] properly parse launch option string

* [fix] support complex launch options for linux

* [fix] applied sonarcube comments

* [fix] apply launch option parsing to shortcut creation

* Change log message

* fix beat saber not launching on windows

---------

Co-authored-by: Zagrios <40181755+Zagrios@users.noreply.github.com>
2026-01-02 13:08:01 +01:00
tleydxdy 25047b6c0a Add workaround for headset with canted views (#918)
See https://gitlab.freedesktop.org/monado/monado/-/merge_requests/2312
2026-01-02 09:43:07 +01:00
Ângelo Tadeucci 2512165898 fix: Playlist search modal losing state (#974)
fix: Playlist modal retaining state

Replaced usage of crypto.randomUUID() in React keys with a persistent 'id' property on modal objects. This ensures stable keys for modal overlays and modal components, preventing unnecessary re-renders and improving React reconciliation.
2025-12-29 23:21:13 +01:00
Zagrios bf1d9408d8 Do not run on forkl 2025-12-23 14:29:45 +01:00
Iluhadesu b076850f63 Add v1.42.0 to bs-versions.json 2025-12-18 16:26:32 +00:00
Zagrios ff8932ea99 [chore] update patreons.json (#971) 2025-12-07 09:17:55 +01:00
Zagrios 6d19d21116 Fix error prompted when uninstalling BSIPA but in fact was successfully uninstalled (#973) 2025-12-06 23:39:38 +01:00
Zagrios 58f8d8e243 Fix race condition when switching version while mods are loading (#972)
* Fix race condition when switching version while mods are loading

* add ts-node for webpack and update workflows
2025-12-06 23:03:46 +01:00
Zagrios 06bb2fe416 Merge branch 'master' of https://github.com/Zagrios/bs-manager 2025-12-06 22:26:56 +01:00
Zagrios 58211271e6 Node 22 to Node 24 + Use tsx instead of ts-node + add @emotion/is-prop-valid 2025-12-06 22:26:53 +01:00
Zagrios e3dc280d34 [chore] update patreons.json (#967) 2025-12-04 11:38:45 +01:00
Iluhadesu 1cd3aa762a Add v1.41.1 to bs-versions.json 2025-12-02 16:47:45 +00:00
Zagrios 94f682b6a2 [chore] update patreons.json (#961) 2025-11-15 08:48:03 +01:00
Zagrios ecb1b57c9b update electron to its latest version 2025-11-05 08:47:57 +01:00
WitheringAway 21fdea9c35 fix bsipa installation error on linux #945 (#948)
prevent overwriting wineprefix (#945)
2025-11-02 15:56:54 +01:00
silentrald 2073d8ffa4 [feat-930] added added date sorting for maps/songs (#931)
* [feat-930] added added date sorting for maps/songs

* [feat-930] add translations

* [feat-930] fixed date format issue

---------

Co-authored-by: Zagrios <40181755+Zagrios@users.noreply.github.com>
2025-11-02 15:45:49 +01:00
Zagrios c9c24479d3 [chore] update patreons.json (#957) 2025-11-02 15:44:52 +01:00
Zagrios cf0921051b Add info again for certificate validation 2025-10-31 07:09:37 +01:00
Iluhadesu 526a3c3ee8 Add v1.40.13 to bs-versions.json 2025-10-30 17:31:11 +00:00
Zagrios 2003507713 Add info for certificate validation 2025-10-26 12:00:39 +01:00
Zagrios 674aab3b4d [chore] update patreons.json (#954) 2025-10-25 12:12:52 +02:00
Zagrios 09c9f19dcb [chore] update patreons.json (#949) 2025-10-14 04:51:07 +02:00
WitheringAway 1cb3cc09a9 fix hhttps error in readme (#947) 2025-10-12 11:00:37 +02:00
WitheringAway e327074459 Switch out old issue creation links with templates (#946)
switch out old issue creation links with templates
2025-10-11 12:14:03 +02:00
Zagrios 56f80f14b2 [chore] update patreons.json (#942) 2025-10-07 08:48:07 +02:00
silentrald a8fc1e1a08 [bugfix-922] fix linux steam pathing issue (#943) 2025-10-07 08:45:18 +02:00
Zagrios d09ca56eeb [chore] update patreons.json (#940) 2025-10-03 21:17:31 +02:00
Zagrios 1df7045683 [chore] update patreons.json (#937) 2025-09-30 04:46:54 +02:00
Iluhadesu fd2795a1f7 Add v1.40.12 to bs-versions.json 2025-09-29 13:39:33 +00:00
Zagrios 47ff527f4d [chore] update patreons.json (#934) 2025-09-25 04:36:59 +02:00
Zagrios c6a3a1249c [chore] update patreons.json (#933) 2025-09-24 09:16:25 +02:00
Iluhadesu 3f1e3e27ba Add v1.40.11 to bs-versions.json 2025-09-23 06:18:39 +00:00
Zagrios 459b7797d0 [chore] update patreons.json (#929) 2025-09-20 07:00:31 +02:00
Zagrios cd89072c4c [chore] update patreons.json (#926) 2025-09-19 04:40:18 +02:00
Zagrios 092f10a6ab [chore] update patreons.json (#925) 2025-09-11 09:13:02 +02:00
Zagrios 4f43f3b833 [chore] update patreons.json (#924) 2025-09-05 08:46:02 +02:00
Zagrios 4a90ac466a [chore] update patreons.json (#920) 2025-08-31 21:43:28 +02:00
Zagrios 5ff8473a85 Update Patreon workflow to create pull requests for changes in patreons.json 2025-08-31 21:40:19 +02:00
Zagrios a5784e9b6e Refactor Patreon update workflow to commit and push changes directly instead of creating pull requests 2025-08-31 21:23:55 +02:00
Zagrios e62b79222b Update Patreon workflow to create pull requests for changes in patreons.json 2025-08-31 21:19:29 +02:00
Zagrios f3359cb43d Add update patreon workflow 2025-08-31 21:03:38 +02:00
Zagrios 390167f0b5 add script to update patreons.json 2025-08-31 20:54:57 +02:00
58 changed files with 3451 additions and 1246 deletions
+170
View File
@@ -0,0 +1,170 @@
import fetch from "node-fetch";
import fs from "fs";
import path from "path";
const API_BASE = "https://www.patreon.com/api/oauth2/v2";
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required env var: ${name}`);
}
return value;
}
function mapTierTitleToType(tierTitle) {
if (!tierTitle) return undefined;
const normalized = String(tierTitle).toLowerCase();
if (normalized.includes("diamond")) return "diamond";
if (normalized.includes("gold")) return "gold";
return undefined;
}
function extractPreferredLink(userAttributes) {
if (!userAttributes || !userAttributes.social_connections) return undefined;
const sc = userAttributes.social_connections;
// Prefer Twitch, then YouTube, then Twitter/X
const providers = ["twitch", "youtube", "twitter"];
for (const provider of providers) {
const entry = sc[provider];
if (entry && entry.url) return entry.url;
}
return undefined;
}
async function fetchAllMembers(accessToken, campaignId) {
let url =
`${API_BASE}/campaigns/${encodeURIComponent(
campaignId
)}/members` +
"?include=user,currently_entitled_tiers" +
"&fields[member]=patron_status,full_name,pledge_relationship_start,last_charge_date" +
"&fields[user]=full_name,vanity,url,social_connections" +
"&fields[tier]=title" +
"&page[count]=100";
const headers = {
Authorization: `Bearer ${accessToken}`,
"User-Agent": process.env.PATREON_USER_AGENT || "BSManager - Patreon Sync",
};
const allMembers = [];
const usersById = new Map();
const tiersById = new Map();
while (url) {
const res = await fetch(url, { headers });
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to fetch members (${res.status}): ${text}`);
}
const json = await res.json();
if (Array.isArray(json.included)) {
for (const inc of json.included) {
if (inc.type === "user") {
usersById.set(inc.id, inc);
} else if (inc.type === "tier") {
tiersById.set(inc.id, inc);
}
}
}
if (Array.isArray(json.data)) {
allMembers.push(...json.data);
}
url = json.links && json.links.next ? json.links.next : undefined;
}
return { members: allMembers, usersById, tiersById };
}
function buildPatreonsList({ members, usersById, tiersById, existingLinkByUsername }) {
const uniqueByUsername = new Map();
for (const member of members) {
const attrs = member.attributes || {};
if (attrs.patron_status !== "active_patron") continue;
const userRel = member.relationships && member.relationships.user && member.relationships.user.data;
const user = userRel ? usersById.get(userRel.id) : undefined;
const userAttrs = (user && user.attributes) || {};
const tiersRel =
member.relationships &&
member.relationships.currently_entitled_tiers &&
member.relationships.currently_entitled_tiers.data;
let type;
if (Array.isArray(tiersRel) && tiersRel.length > 0) {
// Use first tier title match to determine type
const tier = tiersById.get(tiersRel[0].id);
type = mapTierTitleToType(tier && tier.attributes && tier.attributes.title);
}
const username = (userAttrs.vanity || userAttrs.full_name || user?.id || "[ ]").trim();
const link = type === "diamond" ? extractPreferredLink(userAttrs) : undefined;
// Determine first payment/relationship start date (fallback to last_charge_date)
const firstDateStr = attrs.pledge_relationship_start || attrs.last_charge_date || null;
let ts = Number.MAX_SAFE_INTEGER;
if (firstDateStr) {
const parsed = Date.parse(firstDateStr);
ts = Number.isNaN(parsed) ? Number.MAX_SAFE_INTEGER : parsed;
}
const entry = { username };
if (type) entry.type = type;
// Preserve link from existing JSON if present; otherwise use new link
const preservedLink = existingLinkByUsername && existingLinkByUsername.get(username);
if (preservedLink) entry.link = preservedLink;
else if (link) entry.link = link;
// Ensure uniqueness by username
if (!uniqueByUsername.has(username)) {
uniqueByUsername.set(username, { entry, ts });
}
}
return Array.from(uniqueByUsername.values())
.sort((a, b) => a.ts - b.ts)
.map((x) => x.entry);
}
async function main() {
const ACCESS_TOKEN = requireEnv("PATREON_ACCESS_TOKEN");
const CAMPAIGN_ID = requireEnv("PATREON_CAMPAIGN_ID");
const jsonPath = path.resolve(process.cwd(), "assets", "jsons", "patreons.json");
let existing = [];
try {
if (fs.existsSync(jsonPath)) {
const raw = fs.readFileSync(jsonPath, "utf8");
existing = JSON.parse(raw);
}
} catch (_) {
existing = [];
}
const existingLinkByUsername = new Map();
if (Array.isArray(existing)) {
for (const e of existing) {
if (e && e.username && e.link) existingLinkByUsername.set(e.username, e.link);
}
}
const { members, usersById, tiersById } = await fetchAllMembers(ACCESS_TOKEN, CAMPAIGN_ID);
const patreons = buildPatreonsList({ members, usersById, tiersById, existingLinkByUsername });
const output = `${JSON.stringify(patreons, null, "\t")}\n`;
fs.writeFileSync(jsonPath, output, "utf8");
// eslint-disable-next-line no-console
console.log(`Updated ${jsonPath} with ${patreons.length} active patrons.`);
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
process.exitCode = 1;
});
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22.11.0
node-version: 24.11.1
cache: "npm"
# Update package lists
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22.11.0
node-version: 24.11.1
cache: "npm"
- run: npm ci
- run: npm run lint
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22.11.0
node-version: 24.11.1
cache: "npm"
- run: npm ci
- run: npm run build
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22.11.0
node-version: 24.11.1
cache: "npm"
- run: npm ci
- run: npm run build
+50
View File
@@ -0,0 +1,50 @@
name: Update Patreon supporters
on:
schedule:
- cron: "0 0 * * *" # Every day at 00:00 UTC
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
update-patreons:
if: github.repository == 'Zagrios/bs-manager' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
persist-credentials: true
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run Patreon update script
env:
PATREON_ACCESS_TOKEN: ${{ secrets.PATREON_ACCESS_TOKEN }}
PATREON_CAMPAIGN_ID: ${{ secrets.PATREON_CAMPAIGN_ID }}
PATREON_USER_AGENT: BSManager - Patreon Sync (GitHub Action)
run: npx ts-node ./.erb/scripts/update-patreon.js
- name: Create Pull Request (only if changes)
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.GH_TOKEN }}
commit-message: "[chore] update patreons.json"
title: "[chore] update patreons.json"
body: |
Automated daily update of Patreon supporters.
branch: chore/patreon-update
delete-branch: true
add-paths: |
assets/jsons/patreons.json
+3 -3
View File
@@ -24,11 +24,11 @@
</p>
<p>
<a
href="https://github.com/Zagrios/bs-manager/issues/new?assignees=Zagrios&labels=bug&template=-bug--bug-report.md&title=%5BBUG%5D+%3A+">Report
href="https://github.com/Zagrios/bs-manager/issues/new?template=1-bug-report.yaml">Report
Bug</a>
·
<a
href="https://github.com/Zagrios/bs-manager/issues/new?assignees=Zagrios&labels=enhancement&template=-feat---feature-request.md&title=%5BFEAT.%5D+%3A+">Request
href="https://github.com/Zagrios/bs-manager/issues/new?template=2-feature-request.yaml">Request
Feature</a>
·
<a href="https://github.com/Zagrios/bs-manager/security/policy">Report a security vulnerability</a>
@@ -472,7 +472,7 @@
<div>
<h2>Credits</h2>
<ul>
<li><a href="https://github.com/Zagrios">Zagrios</a> - Lead Developer & Founder.</li>
<li><a href="https://github.com/Zagrios">Zagrios</a> - Lead Developer & Founder. (Mathieu Gries)</li>
<li><a href="https://github.com/Iluhadesu">Iluhadesu</a> - Co-Developer & Co-Founder, Discord Bot Developer.</li>
<li><a href="https://github.com/GaetanGrd">GaetanGrd</a> - Co-Developer & Co-Founder, Documentation Lead.</li>
<li><a href="https://github.com/cheddZy">cheddZy</a> - Icon Creator.</li>
+54
View File
@@ -901,5 +901,59 @@
"OculusBinaryId": "10067683139998041",
"ReleaseDate": "1756386614",
"year": "2025"
},
{
"BSVersion": "1.40.11",
"BSManifest": "2631032361926028583",
"OculusBinaryId": "23961660520173735",
"ReleaseURL": "https://steamcommunity.com/games/620980/announcements/detail/539991294395549709",
"ReleaseImg": "https://clan.fastly.steamstatic.com/images/32055887/a626005dae1a96ad8aafa4e65941e26b7918e073.png",
"ReleaseDate": "1758552138",
"year": "2025"
},
{
"BSVersion": "1.40.12",
"BSManifest": "1671670480231783169",
"OculusBinaryId": "24069417846064668",
"ReleaseURL": "https://steamcommunity.com/games/620980/announcements/detail/539991928250303115",
"ReleaseImg": "https://clan.fastly.steamstatic.com/images/32055887/2a5b01ea0613ac1674cb19e18547d0649ccd7464.png",
"ReleaseDate": "1759152049",
"year": "2025"
},
{
"BSVersion": "1.40.13",
"BSManifest": "5625229280277602839",
"OculusBinaryId": "24341751555497961",
"ReleaseURL": "https://steamcommunity.com/games/620980/announcements/detail/637948480724140077",
"ReleaseImg": "https://clan.fastly.steamstatic.com/images/32055887/90da819ce7dd3dbf1600472e0fea4be3c9a28694.png",
"ReleaseDate": "1761838104",
"year": "2025"
},
{
"BSVersion": "1.41.1",
"BSManifest": "1904061226749256371",
"OculusBinaryId": "24664294529910327",
"ReleaseURL": "https://steamcommunity.com/games/620980/announcements/detail/542249438751490862",
"ReleaseImg": "https://clan.fastly.steamstatic.com/images/32055887/6760ebbe87eafaf0215ddfac2858b3716844bc9d.png",
"ReleaseDate": "1764692794",
"year": "2025"
},
{
"BSVersion": "1.42.0",
"BSManifest": "3610593956417791952",
"OculusBinaryId": "24766479246358521",
"ReleaseURL": "https://steamcommunity.com/games/620980/announcements/detail/542251341653738360",
"ReleaseImg": "https://clan.fastly.steamstatic.com/images/32055887/9b9e66ee161deafcf4d9548ccf08dde9e4ae726a.png",
"ReleaseDate": "1766073892",
"year": "2025"
},
{
"BSVersion": "1.42.1",
"BSManifest": "4753635509173254286",
"OculusBinaryId": "25154695844203524",
"ReleaseURL": "https://steamcommunity.com/games/620980/announcements/detail/534373847137255841",
"ReleaseImg": "https://clan.fastly.steamstatic.com/images/32055887/b5b3f993a2a00861db9fdfb2eed68018e5d012ba.png",
"ReleaseDate": "1769692187",
"year": "2026"
}
]
+39 -134
View File
@@ -1,174 +1,79 @@
[
{
"username": "Shidorien"
},
{
"username": "iPixelGalaxy"
},
{
"username": "Emyte",
"type": "gold"
},
{
"username": "GoodOldNervy"
},
{
"username": "Naysy"
},
{
"username": "Falmil"
},
{
"username": "Anonymously42",
"type": "diamond",
"link": "https://www.twitch.tv/anonymously42tv"
},
{
"username": "Burt",
"type": "gold"
},
{
"username": "Phil",
"type": "gold"
},
{
"username": "Protocrush",
"type": "gold"
},
{
"username": "Karlito",
"type": "gold"
},
{
"username": ".sharkey"
},
{
"username": "Z3t4"
},
{
"username": "blot455",
"type": "diamond"
},
{
"username": "Lumberjack462",
"type": "diamond",
"link": "https://www.youtube.com/@lumberjack462"
},
{
"username": "Xero"
},
{
"username": "Minescence"
},
{
"username": "mereknom"
},
{
"username": "Jascha"
},
{
"username": "Celldweller",
"type": "gold"
},
{
"username": "rhythmshade"
},
{
"username": "liborsaf"
"username": "Rhythm Shade"
},
{
"username": "aatame3",
"type": "gold"
},
{
"username": "Joshua"
"username": "Joshua Knick"
},
{
"username": "Stuijvi",
"type": "gold"
},
{
"username": "_ monaka"
},
{
"username": "Better_Axel"
},
{
"username": "Riley",
"type": "gold"
},
{
"username": "Austin"
},
{
"username": "Fatalution",
"type": "diamond",
"link": "https://x.com/fatalution"
},
{
"username": "Taurus Arcade",
"type": "gold"
"username": "Austin Bauman"
},
{
"username": "Mozz_Zm"
},
{
"username": "clapxz"
},
{
"username": "Reflected Chop",
"type": "gold"
},
{
"username": "Furiouspupa"
},
{
"username": "Maximilian"
},
{
"username": "Paul Smith"
},
{
"username": "MunityVR",
"type": "gold"
},
{
{
"username": "Sk4venger"
},
{
"username": "Alexander Herman",
"type": "gold"
},
{
"username": "Arts Rimuro Suraimu",
"type": "gold"
},
{
"username": "Ricardo Boss"
},
{
{
"username": "Jesper Norsted"
},
{
"username": "Marcus Hamm"
},
{
"username": "jxkelol"
{
"username": "bosspie",
"type": "diamond"
},
{
"username": "paperwasp",
"type": "gold"
},
{
"username": "KingCrocman",
"type": "diamond",
"link": "https://www.youtube.com/KingCrocman"
},
{
"username": "bosspie",
"type": "diamond"
},
{
"username": "Camryn Jackson"
}
{
"username": "bones",
"type": "gold"
},
{
"username": "Weed"
},
{
"username": "minefox54"
},
{
"username": "Elysee Buthidi",
"type": "gold"
},
{
"username": "Tindux",
"type": "diamond"
},
{
"username": "Einherjar",
"type": "diamond"
},
{
"username": "Daniel"
},
{
"username": "lethal_loops"
},
{
"username": "egg"
}
]
+10 -1
View File
@@ -111,7 +111,8 @@
"bpm": "BPM",
"duration": "Dauer",
"likes": "Likes",
"date-uploaded": "Hochladedatum"
"date-uploaded": "Hochladedatum",
"added-date": "Hinzugefügt am"
}
},
"playlists": {
@@ -178,6 +179,9 @@
"description-plural": "Diese Version enthält {nb} veraltete Mods. Möchten Sie sie aktualisieren?",
"dont-remind-me": "Nicht mehr daran erinnern",
"update": "Aktualisieren"
},
"third-party-mod-source-not-avaliable": {
"description": "Die Drittanbieter-Datenquelle „{name}“ ist nicht verfügbar. Bitte wechseln Sie in den Einstellungen zurück zu BeatMods und versuchen Sie es erneut."
}
},
"drop-zone": {
@@ -283,6 +287,11 @@
}
}
},
"mod-repos": {
"title": "Modulquelle",
"description": "Wähle eine andere Quelle als BeatMods.",
"website": "Webseite"
},
"patreon": {
"title": "BSManager Unterstützen 💖",
"description": "Unterstütze das Projekt und helfe uns, BSManager kontinuierlich zu verbessern.",
+10 -1
View File
@@ -111,7 +111,8 @@
"bpm": "BPM",
"duration": "Duration",
"likes": "Likes",
"date-uploaded": "Date Uploaded"
"date-uploaded": "Date Uploaded",
"added-date": "Added Date"
}
},
"playlists": {
@@ -178,6 +179,9 @@
"description-plural": "This version has {nb} outdated mods. Do you want to update them?",
"dont-remind-me": "Don't remind me",
"update": "Update"
},
"third-party-mod-source-not-avaliable": {
"description": "The third party mod source \"{name}\" is not avaliable. Please switch to BeatMods at setthings and try again."
}
},
"drop-zone": {
@@ -283,6 +287,11 @@
}
}
},
"mod-repos": {
"title": "Mod Source",
"description": "Choose a source other than BeatMods.",
"website": "website"
},
"patreon": {
"title": "Support BSManager 💖",
"description": "Support the project and help us to provide continuous improvements to BSManager.",
+10 -1
View File
@@ -111,7 +111,8 @@
"bpm": "BPM",
"duration": "Duración",
"likes": "Me gusta",
"date-uploaded": "Fecha de subida"
"date-uploaded": "Fecha de subida",
"added-date": "Fecha de adición"
}
},
"playlists": {
@@ -178,6 +179,9 @@
"description-plural": "Esta versión tiene {nb} mods desactualizados. ¿Quieres actualizarlos?",
"dont-remind-me": "No me lo recuerdes",
"update": "Actualizar"
},
"third-party-mod-source-not-avaliable": {
"description": "La fuente de datos de terceros \"{name}\" no está disponible. Vuelve a BeatMods en la configuración e inténtalo de nuevo."
}
},
"drop-zone": {
@@ -283,6 +287,11 @@
}
}
},
"mod-repos": {
"title": "Fuente del módulo",
"description": "Elija una fuente distinta a BeatMods.",
"website": "sitio web"
},
"patreon": {
"title": "Apoya BSManager 💖",
"description": "Apoya el proyecto y ayúdanos a proporcionar mejoras continuas a BSManager.",
+10 -1
View File
@@ -111,7 +111,8 @@
"bpm": "BPM",
"duration": "Durée",
"likes": "J'aime",
"date-uploaded": "Date de publication"
"date-uploaded": "Date de publication",
"added-date": "Date d'ajout"
}
},
"playlists": {
@@ -178,6 +179,9 @@
"description-plural": "Cette version contient {nb} mods obsolètes. Voulez-vous les mettre à jour ?",
"dont-remind-me": "Ne plus me rappeler",
"update": "Mettre à jour"
},
"third-party-mod-source-not-avaliable": {
"description": "La source de données tierce « {name} » est indisponible. Veuillez revenir à Beatmods dans les paramètres et réessayer."
}
},
"drop-zone": {
@@ -283,6 +287,11 @@
}
}
},
"mod-repos": {
"title": "Source du module",
"description": "Choisissez une autre source que BeatMods.",
"website": "site web"
},
"patreon": {
"title": "Soutenir BSManager 💖",
"description": "Soutiens le projet et aide-nous à fournir des améliorations continues à BSManager.",
+10 -1
View File
@@ -111,7 +111,8 @@
"bpm": "BPM",
"duration": "Durata",
"likes": "Mi piace",
"date-uploaded": "Data di caricamento"
"date-uploaded": "Data di caricamento",
"added-date": "Data di aggiunta"
}
},
"playlists": {
@@ -178,6 +179,9 @@
"description-plural": "Questa versione ha {nb} mod obsoleti. Vuoi aggiornarli?",
"dont-remind-me": "Non ricordarmelo",
"update": "Aggiorna"
},
"third-party-mod-source-not-avaliable": {
"description": "La fonte dati di terze parti \"{name}\" non è disponibile. Torna a BeatMods nelle impostazioni e riprova."
}
},
"drop-zone": {
@@ -283,6 +287,11 @@
}
}
},
"mod-repos": {
"title": "Sorgente del modulo",
"description": "Scegli una fonte diversa da BeatMods.",
"website": "sito web"
},
"patreon": {
"title": "Sostieni BSManager 💖",
"description": "Supporta il progetto e aiutaci a provvedere continui miglioramenti a BSManager.",
+10 -1
View File
@@ -111,7 +111,8 @@
"bpm": "BPM",
"duration": "持続時間",
"likes": "いいね",
"date-uploaded": "アップロード日"
"date-uploaded": "アップロード日",
"added-date": "追加日"
}
},
"playlists": {
@@ -178,6 +179,9 @@
"description-plural": "このバージョンには古いModが{nb}個あります。更新しますか?",
"dont-remind-me": "もう知らせない",
"update": "更新する"
},
"third-party-mod-source-not-avaliable": {
"description": "サードパーティのデータソース「{name}」は利用できません。設定でBeatModsに切り替えて、もう一度お試しください。"
}
},
"drop-zone": {
@@ -283,6 +287,11 @@
}
}
},
"mod-repos": {
"title": "モジュールソース",
"description": "BeatMods以外のソースを選択してください。",
"website": "Webサイト"
},
"patreon": {
"title": "BSManagerをサポートする 💖",
"description": "プロジェクトをサポートし、BSManagerの継続的な応援にご協力ください!",
+10 -1
View File
@@ -111,7 +111,8 @@
"bpm": "BPM",
"duration": "지속 시간",
"likes": "좋아요",
"date-uploaded": "업로드 날짜"
"date-uploaded": "업로드 날짜",
"added-date": "추가 날짜"
}
},
"playlists": {
@@ -178,6 +179,9 @@
"description-plural": "이 버전에는 {nb}개의 구식 모드가 있습니다. 업데이트하시겠습니까?",
"dont-remind-me": "다시는 알리지 않기",
"update": "업데이트"
},
"third-party-mod-source-not-avaliable": {
"description": "타사 데이터 소스 \"{name}\"을 사용할 수 없습니다. 설정에서 BeatMods로 다시 전환한 후 다시 시도해 주세요."
}
},
"drop-zone": {
@@ -283,6 +287,11 @@
}
}
},
"mod-repos": {
"title": "모듈 소스",
"description": "BeatMods 이외의 소스를 선택하세요.",
"website": "웹사이트"
},
"patreon": {
"title": "BSManager를 지원하기 💖",
"description": "프로젝트를 지원하고 BSManager의 지속적인 응원을 부탁드립니다!",
+10 -1
View File
@@ -111,7 +111,8 @@
"bpm": "BPM",
"duration": "Duração",
"likes": "Gostei",
"date-uploaded": "Data de envio"
"date-uploaded": "Data de envio",
"added-date": "Data de adição"
}
},
"playlists": {
@@ -178,6 +179,9 @@
"description-plural": "Esta versão possui {nb} mods desatualizados. Deseja atualizá-los?",
"dont-remind-me": "Não me lembre",
"update": "Atualizar"
},
"third-party-mod-source-not-avaliable": {
"description": "A fonte de dados de terceiros \"{name}\" está indisponível. Por favor, volte para BeatMods nas configurações e tente novamente."
}
},
"drop-zone": {
@@ -283,6 +287,11 @@
}
}
},
"mod-repos": {
"title": "Fonte do módulo",
"description": "Escolha uma fonte diferente de BeatMods.",
"website": "site"
},
"patreon": {
"title": "Apoie BSManager 💖",
"description": "Apoie o projeto e nos ajude a continuar trazendo melhorias para o BSManager.",
+10 -1
View File
@@ -111,7 +111,8 @@
"bpm": "BPM",
"duration": "Длительность",
"likes": "Лайки",
"date-uploaded": "Дата загрузки"
"date-uploaded": "Дата загрузки",
"added-date": "Дата добавления"
}
},
"playlists": {
@@ -178,6 +179,9 @@
"description-plural": "В этой версии есть {nb} устаревших модов. Хотите их обновить?",
"dont-remind-me": "Не напоминать мне",
"update": "Обновить"
},
"third-party-mod-source-not-avaliable": {
"description": "Сторонний источник данных \"{name}\" недоступен. Пожалуйста, переключитесь обратно на BeatMods в настройках и попробуйте снова."
}
},
"drop-zone": {
@@ -283,6 +287,11 @@
}
}
},
"mod-repos": {
"title": "Исходный код модуля",
"description": "Выберите источник, отличный от BeatMods.",
"website": "веб-сайт"
},
"patreon": {
"title": "Поддержи BSManager 💖",
"description": "Поддержи проект, чтобы мы могли дальше улучшать BSManager.",
+10 -1
View File
@@ -111,7 +111,8 @@
"bpm": "BPM",
"duration": "Haba",
"likes": "Likes",
"date-uploaded": "Petsa ng Pagka-upload"
"date-uploaded": "Petsa ng Pagka-upload",
"added-date": "Petsa ng Pagdaragdag"
}
},
"playlists": {
@@ -178,6 +179,9 @@
"description-plural": "Ang bersyon na ito ay may {nb} lipas na mga mod. Gusto mo bang i-update ang mga ito?",
"dont-remind-me": "Huwag akong paalalahanan",
"update": "I-update"
},
"third-party-mod-source-not-avaliable": {
"description": "Hindi magagamit ang third-party data source na \"{name}\". Bumalik sa BeatMods sa mga setting at subukang muli."
}
},
"drop-zone": {
@@ -283,6 +287,11 @@
}
}
},
"mod-repos": {
"title": "Pinagmulan ng modyul",
"description": "Pumili ng ibang mapagkukunan maliban sa BeatMods.",
"website": "website"
},
"patreon": {
"title": "Supportahan ang BSManager 💖",
"description": "Suportahan ang proyekto at tulungan kami na magbigay ng patuloy na mga pagdagdag ng feature sa BSManager.",
+10 -1
View File
@@ -111,7 +111,8 @@
"bpm": "BPM",
"duration": "Довжина",
"likes": "Лайки",
"date-uploaded": "Дата завантаження"
"date-uploaded": "Дата завантаження",
"added-date": "Дата додавання"
}
},
"playlists": {
@@ -178,6 +179,9 @@
"description-plural": "Ця версія має {nb} застаріл(-ий/-і/-их) мод(-и/-ів). Ви хочете оновити їх?",
"dont-remind-me": "Не нагадувати",
"update": "Оновити"
},
"third-party-mod-source-not-avaliable": {
"description": "Джерело даних стороннього розробника \"{name}\" недоступне. Будь ласка, поверніться до BeatMods у налаштуваннях і спробуйте ще раз."
}
},
"drop-zone": {
@@ -283,6 +287,11 @@
}
}
},
"mod-repos": {
"title": "Джерело модуля",
"description": "Виберіть джерело, відмінне від BeatMods.",
"website": "вебсайт"
},
"patreon": {
"title": "Підтримайте BSManager 💖",
"description": "Підтримайте проєкт і допоможіть нам продовжувати вдосконалювати BSManager.",
+10 -1
View File
@@ -111,7 +111,8 @@
"bpm": "BPM",
"duration": "持續時間",
"likes": "喜歡",
"date-uploaded": "上傳日期"
"date-uploaded": "上傳日期",
"added-date": "新增日期"
}
},
"playlists": {
@@ -178,6 +179,9 @@
"description-plural": "此版本有 {nb} 個過時的模組。您想更新它們嗎?",
"dont-remind-me": "不要提醒我",
"update": "更新"
},
"third-party-mod-source-not-avaliable": {
"description": "第三方資料來源「{name}」不可用,請在設定中切換回BeatMods再試。"
}
},
"drop-zone": {
@@ -283,6 +287,11 @@
}
}
},
"mod-repos": {
"title": "模組來源",
"description": "選擇一個除BeatMods之外的來源",
"website": "網站"
},
"patreon": {
"title": "支持 BSManager 💖",
"description": "支持該項目並幫助我們持續改進 BSManager。",
+10 -1
View File
@@ -111,7 +111,8 @@
"bpm": "BPM",
"duration": "持续时间",
"likes": "喜欢",
"date-uploaded": "上传日期"
"date-uploaded": "上传日期",
"added-date": "添加日期"
}
},
"playlists": {
@@ -178,6 +179,9 @@
"description-plural": "此版本有 {nb} 个过时的模组。您想更新它们吗?",
"dont-remind-me": "不要提醒我",
"update": "更新"
},
"third-party-mod-source-not-avaliable": {
"description": "第三方数据源“{name}”不可用,请在设置中切换回BeatMods再试。"
}
},
"drop-zone": {
@@ -283,6 +287,11 @@
}
}
},
"mod-repos": {
"title": "模组源",
"description": "选择一个除BeatMods之外的源",
"website": "网站"
},
"patreon": {
"title": "支持 BSManager 💖",
"description": "支持该项目并帮助我们持续改进 BSManager。",
+3 -1
View File
@@ -15,7 +15,9 @@ const config = {
afterSign: ".erb/scripts/notarize.js",
afterPack: ".erb/scripts/after-pack.js",
win: {
signingHashAlgorithms: ["sha256"],
signtoolOptions: {
signingHashAlgorithms: ["sha256"],
},
target: [
"nsis",
"nsis-web"
+2 -1
View File
@@ -12,8 +12,9 @@ const config: Config = {
},
moduleFileExtensions: ["js", "jsx", "ts", "tsx", "json"],
moduleDirectories: ["node_modules", "src"],
testPathIgnorePatterns: ["release/app/dist"],
testPathIgnorePatterns: ["<rootDir>/release/app"],
setupFiles: ["./.erb/scripts/check-build-exists.ts"],
modulePathIgnorePatterns: ["<rootDir>/release/app"]
};
export default config;
+1569 -788
View File
File diff suppressed because it is too large Load Diff
+21 -16
View File
@@ -4,25 +4,26 @@
"main": "./.erb/dll/main.bundle.dev.js",
"version": "1.5.4",
"scripts": {
"build-rust-scripts": "ts-node ./.erb/scripts/build-rust-scripts.js",
"build-rust-scripts": "tsx ./.erb/scripts/build-rust-scripts.js",
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
"build:dll": "cross-env NODE_ENV=development webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
"build:main": "cross-env NODE_ENV=production webpack --config ./.erb/configs/webpack.config.main.prod.ts",
"build:renderer": "cross-env NODE_ENV=production webpack --config ./.erb/configs/webpack.config.renderer.prod.ts",
"postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && npm run build:dll",
"postinstall": "tsx .erb/scripts/check-native-dep.js && electron-builder install-app-deps && npm run build:dll",
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
"prestart": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.dev.ts",
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --config electron-builder.config.js && npm run build:dll",
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run prestart && npm run start:renderer",
"package": "tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --config electron-builder.config.js && npm run build:dll",
"start": "tsx ./.erb/scripts/check-port-in-use.js && npm run prestart && npm run start:renderer",
"start:main": "concurrently -k \"cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --watch --config ./.erb/configs/webpack.config.main.dev.ts\" \"electronmon .\"",
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
"test": "jest",
"test": "jest ./src/__tests__/**/*.test.ts",
"test:unit": "jest ./src/__tests__/unit",
"publish": "npm run build && electron-builder -c.win.certificateSha1=206941d969c4fa8a0e04d9427def361e13b02fd0 --config electron-builder.config.js --publish always --win --x64",
"publish:linux": "npm run build && electron-builder --config electron-builder.config.js --publish never --linux --x64",
"publish:flatpak": "npm run build && env DEBUG='@malept/flatpak-bundler' npx electron-builder --config electron-builder.config.js --publish never --linux flatpak"
"publish:flatpak": "npm run build && env DEBUG='@malept/flatpak-bundler' npx electron-builder --config electron-builder.config.js --publish never --linux flatpak",
"update:patreon": "tsx ./.erb/scripts/update-patreon.js"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
@@ -102,16 +103,16 @@
"autoprefixer": "^10.4.17",
"browserslist-config-erb": "^0.0.3",
"chalk": "^4.1.2",
"concurrently": "^8.2.2",
"concurrently": "^9.2.1",
"core-js": "^3.36.0",
"cross-env": "^7.0.3",
"cross-env": "^10.1.0",
"css-loader": "^6.10.0",
"css-minimizer-webpack-plugin": "^6.0.0",
"detect-port": "^1.5.1",
"electron": "^36.4.0",
"electron-builder": "^25.1.8",
"detect-port": "^2.1.0",
"electron": "39.2.7",
"electron-builder": "^26.0.12",
"electron-devtools-installer": "^4.0.0",
"electronmon": "^2.0.3",
"electronmon": "^2.0.4",
"eslint": "^8.56.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-erb": "^4.1.0",
@@ -144,6 +145,8 @@
"terser-webpack-plugin": "^5.3.10",
"ts-jest": "^29.1.2",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsx": "^4.19.2",
"typescript": "^5.3.3",
"url-loader": "^4.1.1",
"webpack": "^5.90.3",
@@ -153,6 +156,7 @@
"webpack-merge": "^5.10.0"
},
"dependencies": {
"@emotion/is-prop-valid": "1.4.0",
"@internationalized/date": "^3.5.4",
"@nextui-org/date-picker": "^2.0.7",
"@nextui-org/react": "^2.3.6",
@@ -170,7 +174,7 @@
"electron-updater": "^6.3.9",
"fast-deep-equal": "^3.1.3",
"format-duration": "^3.0.2",
"framer-motion": "^12.17.0",
"framer-motion": "12.23.26",
"fs-extra": "^11.3.0",
"global-agent": "^3.0.0",
"got": "^14.4.7",
@@ -180,7 +184,7 @@
"node-abi": "^4.2.0",
"node-fetch": "^3.3.2",
"pako": "^2.1.0",
"protobufjs": "^7.5.3",
"protobufjs": "^8.0.0",
"qrcode.react": "^4.2.0",
"query-process": "^0.0.3",
"react": "^18.2.0",
@@ -195,13 +199,14 @@
"rfdc": "^1.4.1",
"rxjs": "^7.8.2",
"sanitize-filename": "^1.6.3",
"semver": "^7.7.2",
"semver": "7.7.3",
"serialize-error": "^12.0.0",
"striptags": "^4.0.0-alpha.4",
"tailwind-merge": "^3.0.2",
"tailwindcss-scoped-groups": "^2.0.0",
"tippy.js": "^6.3.7",
"to-ico": "^1.1.5",
"tough-cookie": "^6.0.0",
"use-double-click": "^1.0.5",
"use-fit-text": "^2.4.0",
"yauzl": "^3.2.0"
@@ -244,6 +249,6 @@
"logLevel": "quiet"
},
"volta": {
"node": "22.14.0"
"node": "24.11.1"
}
}
+332 -10
View File
@@ -13,7 +13,7 @@
"@resvg/resvg-js": "2.6.2",
"ps-list": "^7.2.0",
"query-process": "^0.0.3",
"regedit-rs": "^1.0.2"
"regedit-rs": "1.0.4"
}
},
"node_modules/@resvg/resvg-js": {
@@ -38,6 +38,118 @@
"@resvg/resvg-js-win32-x64-msvc": "2.6.2"
}
},
"node_modules/@resvg/resvg-js-android-arm-eabi": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz",
"integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==",
"cpu": [
"arm"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-android-arm64": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz",
"integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-darwin-arm64": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz",
"integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-darwin-x64": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz",
"integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-linux-arm-gnueabihf": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz",
"integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==",
"cpu": [
"arm"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-linux-arm64-gnu": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz",
"integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-linux-arm64-musl": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz",
"integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-linux-x64-gnu": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz",
@@ -54,6 +166,54 @@
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-linux-x64-musl": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz",
"integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-win32-arm64-msvc": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz",
"integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-win32-ia32-msvc": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz",
"integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==",
"cpu": [
"ia32"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@resvg/resvg-js-win32-x64-msvc": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz",
@@ -99,6 +259,134 @@
"query-process-win32-x64-msvc": "0.0.3"
}
},
"node_modules/query-process-android-arm-eabi": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/query-process-android-arm-eabi/-/query-process-android-arm-eabi-0.0.3.tgz",
"integrity": "sha512-NB+9T+/poBcygDiG+7d2lJSeioP5ZyLT0HwUBdnLgNWvNCpzbJxydup1SLpnt5G2NF6nlTgbUZlH4hvxY7t/bw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/query-process-android-arm64": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/query-process-android-arm64/-/query-process-android-arm64-0.0.3.tgz",
"integrity": "sha512-TiAw2yO62vQzM0s02471LToHyUs/PRkLGGlA715z10BPIM5NckzIe245AI45CDyH6tE/3nVHUd0LX/dfjQtTcw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/query-process-linux-arm-gnueabihf": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/query-process-linux-arm-gnueabihf/-/query-process-linux-arm-gnueabihf-0.0.3.tgz",
"integrity": "sha512-w7NLHBKt7qselCDoh/PVWyeuEb2NiiDDf1A9UB4K3Ns/H24hK3NbfMMBm8mCFxUnFUqoVX8O+VMs7dD/FNlTxA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/query-process-linux-arm64-gnu": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/query-process-linux-arm64-gnu/-/query-process-linux-arm64-gnu-0.0.3.tgz",
"integrity": "sha512-nrPHjnSqCBuuN7IZkZWOnBpiJjyKOjfpT0Xt2EjSGp+I7C0CHbnhCL/vG1hBzaWZn0mxJ5nf0c5N0Fja7krnpQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/query-process-linux-arm64-musl": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/query-process-linux-arm64-musl/-/query-process-linux-arm64-musl-0.0.3.tgz",
"integrity": "sha512-vk8xMYTnS1PRKhwJgqLKyBAGIFP2CYZW2BsaKuNgQKUrDv7NnBaG7yukZT8niHNO1ycSrFpR/xqeJ8R23hO07A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/query-process-linux-x64-gnu": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/query-process-linux-x64-gnu/-/query-process-linux-x64-gnu-0.0.3.tgz",
"integrity": "sha512-j1tGcNnGyVCABJ1PHZD1gKuyuomNzsPow3NDuZzUGjPqvKu67RsmKJJJOhMyT1SvUkcqdLFdtS1KTD20I4GS5Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/query-process-linux-x64-musl": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/query-process-linux-x64-musl/-/query-process-linux-x64-musl-0.0.3.tgz",
"integrity": "sha512-AOGrrI/Qcb30iKFstiW60zNG/HQevCY0Q38eMHGd7LrBZ5sEN7NzZOjzKp43KeNHejuZBDMxf0gdGAqDznOAng==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/query-process-win32-arm64-msvc": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/query-process-win32-arm64-msvc/-/query-process-win32-arm64-msvc-0.0.3.tgz",
"integrity": "sha512-LHvxbzMFwPPUXHe4bDCAqs1bYbp8j0bMW8XyJGPZnIYrnwHz6g8VE5WQEtXQxSznsbfVxAKJXUtn088EjDVazw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/query-process-win32-x64-msvc": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/query-process-win32-x64-msvc/-/query-process-win32-x64-msvc-0.0.3.tgz",
@@ -115,25 +403,59 @@
}
},
"node_modules/regedit-rs": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/regedit-rs/-/regedit-rs-1.0.2.tgz",
"integrity": "sha512-4vEgiZNO1FCG8z/Zx3v/6PU1+eZ+ELe6R0ca+VB96Vw+Mi3M0IVHAjtMFbl97lUSX11dJqpyousX/wY8QcI1lA==",
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/regedit-rs/-/regedit-rs-1.0.4.tgz",
"integrity": "sha512-G+gESK8PvuqUec8LT4TrkbGvOOw4EzGd+kRn4zzmnnrWu0mgBD6P0zQPj+9EUIYW8L4BPETWiSkkyAFamc4JEg==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"regedit-rs-win32-arm64-msvc": "1.0.2",
"regedit-rs-win32-ia32-msvc": "1.0.2",
"regedit-rs-win32-x64-msvc": "1.0.2"
"regedit-rs-win32-arm64-msvc": "1.0.4",
"regedit-rs-win32-ia32-msvc": "1.0.4",
"regedit-rs-win32-x64-msvc": "1.0.4"
}
},
"node_modules/regedit-rs-win32-arm64-msvc": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/regedit-rs-win32-arm64-msvc/-/regedit-rs-win32-arm64-msvc-1.0.4.tgz",
"integrity": "sha512-4oBCk+r8BnXT/SHJ+b6cKlhSy53oX++XdyqCCBOfrvy8hP7EX7rsp02vPgLErwCzQ4E42QLPIO0FoLRmGzTztg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/regedit-rs-win32-ia32-msvc": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/regedit-rs-win32-ia32-msvc/-/regedit-rs-win32-ia32-msvc-1.0.4.tgz",
"integrity": "sha512-hhzZ7QCtQqUiM73H1NfqeRDU6eos778Kynk5SaV0IMHiNOgMgj2Mb0xQdWRovJgpquRgm+x0DS3cn2MbNZwuWQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/regedit-rs-win32-x64-msvc": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/regedit-rs-win32-x64-msvc/-/regedit-rs-win32-x64-msvc-1.0.2.tgz",
"integrity": "sha512-ccCSyd5vWBKVWftBKLKzegqwwPMWcQtIW0ub66dCFFuv2s+x2EcZZWGdD9dVXX2Z6V9DU2JRPKgWUNjVPaj6Xg==",
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/regedit-rs-win32-x64-msvc/-/regedit-rs-win32-x64-msvc-1.0.4.tgz",
"integrity": "sha512-kb+965y6NKQijtCYYSvvE8puKAT3uyrHpr6HmZdGDZQb/iKNY2GmUX1/YrlYf9Kavdr5IJ9rkat6pokND+Npzw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
+4 -4
View File
@@ -9,18 +9,18 @@
"url": "https://github.com/Zagrios/bs-manager"
},
"scripts": {
"rebuild": "node -r ts-node/register ../../.erb/scripts/electron-rebuild.js",
"link-modules": "node -r ts-node/register ../../.erb/scripts/link-modules.ts",
"rebuild": "tsx ../../.erb/scripts/electron-rebuild.js",
"link-modules": "tsx ../../.erb/scripts/link-modules.ts",
"postinstall": "npm run rebuild && npm run link-modules"
},
"dependencies": {
"@resvg/resvg-js": "2.6.2",
"ps-list": "^7.2.0",
"query-process": "^0.0.3",
"regedit-rs": "^1.0.2"
"regedit-rs": "1.0.4"
},
"license": "MIT",
"volta": {
"node": "20.11.0"
"node": "24.11.1"
}
}
+44 -14
View File
@@ -3,59 +3,89 @@ import { parseEnvString } from "main/helpers/env.helpers";
describe("Test parseEnvString", () => {
it("Empty", () => {
const envVars = parseEnvString("");
expect(envVars).toEqual({});
const { env, command } = parseEnvString("");
expect(env).toEqual({});
expect(command).toEqual("");
});
it("Single test; no quotes", () => {
const envString = "HELLO=World!";
const envVars = parseEnvString(envString);
expect(envVars).toEqual({
const { env, command } = parseEnvString(envString);
expect(env).toEqual({
HELLO: "World!",
});
expect(command).toEqual("");
});
it("Single test; single quotes", () => {
const envString = "SINGLE_QOUTE='Single quote with spaces'";
const envVars = parseEnvString(envString);
expect(envVars).toEqual({
const { env, command } = parseEnvString(envString);
expect(env).toEqual({
SINGLE_QOUTE: "Single quote with spaces",
});
expect(command).toEqual("");
});
it("Single test; double quotes", () => {
const envString = 'DOUBLE_QOUTE="Some random quote."';
const envVars = parseEnvString(envString);
expect(envVars).toEqual({
const { env, command } = parseEnvString(envString);
expect(env).toEqual({
DOUBLE_QOUTE: "Some random quote.",
});
expect(command).toEqual("");
});
it("Single test; empty value", () => {
const envString = "EMPTY=";
const envVars = parseEnvString(envString);
expect(envVars).toEqual({
const { env, command } = parseEnvString(envString);
expect(env).toEqual({
EMPTY: "",
});
expect(command).toEqual("");
});
it("Multiple test; combined", () => {
const envString = `HELLO=World! DOUBLE_QUOTE="Two Words" SINGLE_QUOTE='' EMPTY=`
const envVars = parseEnvString(envString);
expect(envVars).toEqual(expect.objectContaining({
const { env, command } = parseEnvString(envString);
expect(env).toEqual(expect.objectContaining({
HELLO: "World!",
DOUBLE_QUOTE: "Two Words",
SINGLE_QUOTE: "",
EMPTY: ""
}));
expect(command).toEqual("");
});
it("Key with numbers and lower case", () => {
const envString = "H3ll0=world";
const envVars = parseEnvString(envString);
expect(envVars).toEqual({
const { env, command } = parseEnvString(envString);
expect(env).toEqual({
H3ll0: "world",
});
expect(command).toEqual("");
});
it("Simple command", () => {
const { env, command } = parseEnvString("some-command");
expect(env).toEqual({});
expect(command).toBe("some-command");
});
it("Env with command", () => {
const { env, command } = parseEnvString("SAMPLE=value some-command");
expect(env).toEqual(expect.objectContaining({
SAMPLE: "value"
}));
expect(command).toBe("some-command");
});
it("Complex with %command%", () => {
const envString = "KEY=value gamescope -h 720 -H 1440 -S integer -- %command% ";
const { env, command } = parseEnvString(envString);
expect(env).toEqual(expect.objectContaining({
KEY: "value"
}));
expect(command).toBe("gamescope -h 720 -H 1440 -S integer -- %command%");
})
});
@@ -0,0 +1,114 @@
import { parseLaunchOptions } from "main/helpers/launchOptions.helper";
const SAMPLE_EXE = `"Beat Saber.exe"`;
const PROTON_EXE = `"proton" run ${SAMPLE_EXE}`;
describe("Test parseLaunchOptions", () => {
it("Empty", () => {
const {
env, cmdlet, args
} = parseLaunchOptions("", { commandReplacement: SAMPLE_EXE });
expect(env).toEqual({});
expect(cmdlet).toBe(SAMPLE_EXE);
expect(args).toBe("");
});
it("Envs", () => {
const { env, cmdlet, args } = parseLaunchOptions(
`HELLO=World! DOUBLE_QUOTE="Two Words" SINGLE_QUOTE='' EMPTY=`,
{ commandReplacement: SAMPLE_EXE }
);
expect(env).toEqual(expect.objectContaining({
HELLO: "World!",
DOUBLE_QUOTE: "Two Words",
SINGLE_QUOTE: "",
EMPTY: ""
}));
expect(cmdlet).toEqual(SAMPLE_EXE);
expect(args).toEqual("");
});
it("Env with %command%", () => {
const { env, cmdlet, args } = parseLaunchOptions(
`TEST=TEST %command%`,
{ commandReplacement: SAMPLE_EXE }
);
expect(env).toEqual(expect.objectContaining({
TEST: "TEST",
}));
expect(cmdlet).toEqual(SAMPLE_EXE);
expect(args).toEqual("");
});
it("Envs with arguments", () => {
const { env, cmdlet, args } = parseLaunchOptions(
`HELLO=World! DOUBLE_QUOTE="Two Words" SINGLE_QUOTE='' EMPTY= %command% --vr-mode`,
{ commandReplacement: SAMPLE_EXE }
);
expect(env).toEqual(expect.objectContaining({
HELLO: "World!",
DOUBLE_QUOTE: "Two Words",
SINGLE_QUOTE: "",
EMPTY: ""
}));
expect(cmdlet).toEqual(SAMPLE_EXE);
expect(args).toEqual("--vr-mode");
});
it("Linux Command 1", () => {
const { env, cmdlet, args } = parseLaunchOptions(
"gamemoderun %command%",
{ commandReplacement: PROTON_EXE }
);
expect(env).toEqual({});
expect(cmdlet).toBe("gamemoderun");
expect(args).toBe(PROTON_EXE);
});
it("Linux Command 2", () => {
const { env, cmdlet, args } = parseLaunchOptions(
"mangohud %command%",
{ commandReplacement: PROTON_EXE }
);
expect(env).toEqual({});
expect(cmdlet).toBe("mangohud");
expect(args).toBe(PROTON_EXE);
});
it("Linux Command 3", () => {
const { env, cmdlet, args } = parseLaunchOptions(
"gamescope -h 720 -H 1440 -S integer -- %command%",
{ commandReplacement: PROTON_EXE }
);
expect(env).toEqual({});
expect(cmdlet).toBe("gamescope");
expect(args).toBe(`-h 720 -H 1440 -S integer -- ${PROTON_EXE}`);
});
it("Linux Command 4", () => {
const { env, cmdlet, args } = parseLaunchOptions(
`LD_PRELOAD="" gamescope --hdr-enabled -f -h 1440 -r 144 --force-grab-cursor --framerate-limit 144 --mangoapp -- %command%`,
{ commandReplacement: PROTON_EXE }
);
expect(env).toEqual({
LD_PRELOAD: ""
});
expect(cmdlet).toBe("gamescope");
expect(args).toBe(`--hdr-enabled -f -h 1440 -r 144 --force-grab-cursor --framerate-limit 144 --mangoapp -- ${PROTON_EXE}`);
});
it("Complex Linux Command", () => {
const { env, cmdlet, args } = parseLaunchOptions(
"WINEPREFIX=some-path HELLO=World gamescope -h 720 -H 1440 -S integer -- %command% --debug",
{ commandReplacement: PROTON_EXE }
);
expect(env).toEqual(expect.objectContaining({
WINEPREFIX: "some-path",
HELLO: "World",
}));
expect(cmdlet).toBe("gamescope");
expect(args).toBe(`-h 720 -H 1440 -S integer -- ${PROTON_EXE} --debug`);
});
});
+6 -12
View File
@@ -27,7 +27,6 @@ jest.mock("electron-log", () => ({
jest.mock("ps-list", () => (): unknown[] => []);
const IS_WINDOWS = process.platform === "win32";
const IS_LINUX = process.platform === "linux";
describe("Test os.helpers bsmSpawn", () => {
@@ -51,6 +50,7 @@ describe("Test os.helpers bsmSpawn", () => {
STEAM_COMPAT_CLIENT_INSTALL_PATH: "/steam",
STEAM_COMPAT_APP_ID: BS_APP_ID,
SteamEnv: "1",
OXR_PARALLEL_VIEWS: "1",
});
}
});
@@ -89,14 +89,11 @@ describe("Test os.helpers bsmSpawn", () => {
it("Complex spawn command call (Mods install)", () => {
bsmSpawn(`"./BSIPA.exe" "./Beat Saber.exe" -n`, {
log: BsmShellLog.Command,
linux: { prefix: `"./wine64"` },
});
expect(spawnSpy).toHaveBeenCalledTimes(1);
expect(spawnSpy).toHaveBeenCalledWith(
process.platform === "win32"
? `"./BSIPA.exe" "./Beat Saber.exe" -n`
: `"./wine64" "./BSIPA.exe" "./Beat Saber.exe" -n`,
`"./BSIPA.exe" "./Beat Saber.exe" -n`,
expect.anything()
);
@@ -112,14 +109,11 @@ describe("Test os.helpers bsmSpawn", () => {
env: BS_ENV,
},
log: BsmShellLog.Command,
linux: { prefix: `"./proton" run` },
});
expect(spawnSpy).toHaveBeenCalledTimes(1);
expect(spawnSpy).toHaveBeenCalledWith(
IS_WINDOWS
? `"./Beat Saber.exe" --no-yeet fpfc`
: `"./proton" run "./Beat Saber.exe" --no-yeet fpfc`,
`"./Beat Saber.exe" --no-yeet fpfc`,
expect.objectContaining({
cwd: "/",
detached: true,
@@ -140,14 +134,15 @@ describe("Test os.helpers bsmSpawn", () => {
"STEAM_COMPAT_INSTALL_PATH",
"STEAM_COMPAT_CLIENT_INSTALL_PATH",
"STEAM_COMPAT_APP_ID",
"SteamEnv"
"SteamEnv",
"OXR_PARALLEL_VIEWS"
];
const newEnv = {
...BS_ENV,
something: "else",
more: "tests",
};
bsmSpawn(`"./Beat Saber.exe"`, {
bsmSpawn(`"./proton" run "./Beat Saber.exe"`, {
args: ["--no-yeet", "fpfc"],
options: {
cwd: "/",
@@ -155,7 +150,6 @@ describe("Test os.helpers bsmSpawn", () => {
env: newEnv,
},
log: BsmShellLog.Command,
linux: { prefix: `"./proton" run` },
flatpak: {
host: true,
env: flatpakEnv,
+62 -41
View File
@@ -21,6 +21,7 @@ enum EnvParserState {
QUOTE_VALUE,
DQUOTE_VALUE,
SPACE,
EXIT,
ERROR,
};
@@ -28,7 +29,19 @@ const isAlphaCharacter = (c: string) =>
(c >= "a" && c <= "z") || (c >= "A" && c <= "Z");
const isNumber = (c: string) => c >= "0" && c <= "9";
export function parseEnvString(envString: string): Record<string, string> {
/**
* Parses the env values from an envString command
*
* @params envString
* @returns ({
* env - parsed environment variables
* command - part of the env string which is the command
* })
*/
export function parseEnvString(envString: string): {
env: Record<string, string>;
command: string;
} {
const envVars: Record<string, string> = {};
let state: EnvParserState = EnvParserState.NAME_START;
@@ -39,13 +52,13 @@ export function parseEnvString(envString: string): Record<string, string> {
switch (state) {
case EnvParserState.NAME_START:
index = pos;
if (isAlphaCharacter(c) || c === "_") {
state = EnvParserState.NAME;
index = pos;
} else if (c !== " ") {
state = EnvParserState.ERROR;
state = EnvParserState.EXIT;
}
break;
break;
case EnvParserState.NAME:
if (c === "=") {
@@ -53,57 +66,65 @@ export function parseEnvString(envString: string): Record<string, string> {
newName = envString.substring(index, pos);
index = pos + 1;
} else if (!isAlphaCharacter(c) && !isNumber(c) && c !== "_") {
state = EnvParserState.ERROR;
state = EnvParserState.EXIT;
}
break;
break;
case EnvParserState.VALUE_START:
if (c === "'") {
++index;
state = EnvParserState.QUOTE_VALUE;
} else if (c === '"') {
++index;
state = EnvParserState.DQUOTE_VALUE;
} else if (c === " ") {
state = EnvParserState.NAME_START;
envVars[newName] = "";
} else {
state = EnvParserState.VALUE;
}
break;
++index;
state = EnvParserState.QUOTE_VALUE;
} else if (c === '"') {
++index;
state = EnvParserState.DQUOTE_VALUE;
} else if (c === " ") {
state = EnvParserState.NAME_START;
envVars[newName] = "";
} else {
state = EnvParserState.VALUE;
}
break;
case EnvParserState.VALUE:
if (c === " ") {
state = EnvParserState.NAME_START;
envVars[newName] = envString.substring(index, pos);
}
break;
state = EnvParserState.NAME_START;
envVars[newName] = envString.substring(index, pos);
}
break;
case EnvParserState.QUOTE_VALUE:
if (c === "'") {
state = EnvParserState.SPACE;
envVars[newName] = envString.substring(index, pos);
}
break;
state = EnvParserState.SPACE;
envVars[newName] = envString.substring(index, pos);
}
break;
case EnvParserState.DQUOTE_VALUE:
if (c === '"') {
state = EnvParserState.SPACE;
envVars[newName] = envString.substring(index, pos);
}
break;
state = EnvParserState.SPACE;
envVars[newName] = envString.substring(index, pos);
}
break;
case EnvParserState.SPACE:
if (c === " ") {
state = EnvParserState.NAME_START;
} else {
state = EnvParserState.ERROR;
}
break;
state = EnvParserState.NAME_START;
} else {
state = EnvParserState.ERROR;
}
break;
default:
}
// Early exit
if (state === EnvParserState.EXIT) {
return {
env: envVars,
command: envString.substring(index).trim()
};
}
if (state === EnvParserState.ERROR) {
throw new CustomError(
`parseEnvString failed: invalid character at position ${pos}`,
@@ -114,15 +135,15 @@ export function parseEnvString(envString: string): Record<string, string> {
if (state === EnvParserState.VALUE_START || state === EnvParserState.VALUE) {
envVars[newName] = envString.substring(index);
return envVars;
return { env: envVars, command: "" };
}
if (state === EnvParserState.NAME_START || state === EnvParserState.SPACE) {
return envVars;
return { env: envVars, command: "" };
}
throw new CustomError(
"parseEnvString failed: invalid ending state",
"generic.env.parse"
);
return {
env: envVars,
command: envString.substring(index + 1).trim(),
}
}
+2 -2
View File
@@ -32,7 +32,7 @@ export async function deleteFile(filepath: string) {
log.info("Deleting file", `"${filepath}"`);
await unlink(filepath);
} catch (error: any) {
log.error("Could not delete file", `"${filepath}"`);
log.error("Could not delete file", `"${filepath}"`, error);
throw CustomError.fromError(error, "generic.fs.delete-file");
}
}
@@ -42,7 +42,7 @@ export function deleteFileSync(filepath: string) {
log.info("Deleting file", `"${filepath}"`);
unlinkSync(filepath);
} catch (error: any) {
log.error("Could not delete file", `"${filepath}"`);
log.error("Could not delete file", `"${filepath}"`, error);
throw CustomError.fromError(error, "generic.fs.delete-file");
}
}
+59
View File
@@ -0,0 +1,59 @@
import { parseEnvString } from "./env.helpers";
const COMMAND_KEYWORD = "%command%";
/**
* Parses the launch options command into parts to be used for bsmSpawn
*
* @params command
* @params options.commandReplacement - Replaces the %command% string
* @params options.linux - If the application is running under linux. Can be toggled in testing to check if the logic works.
* @returns {
* env - environment variables
* cmdlet - BS.exe or a binary executable like gamemoderun and gamescope
* args - Arguments for the cmdlet.
* }
*/
export function parseLaunchOptions(launchOption: string, options: {
commandReplacement: string;
}): {
env: Record<string, string>;
cmdlet: string;
args: string;
} {
if (!launchOption) {
return { env: {}, cmdlet: options.commandReplacement, args: "" };
}
const parsed = parseEnvString(launchOption);
const { env } = parsed;
// If launch options only contains env strings
if (!parsed.command) {
return { env, cmdlet: options.commandReplacement, args: "" };
}
const command = parsed.command.indexOf(COMMAND_KEYWORD) === -1
? `${options.commandReplacement} ${parsed.command}`
: parsed.command.replace(COMMAND_KEYWORD, options.commandReplacement);
// Offset if it starts with a " or '
let offset = 0;
if (command.startsWith('"')) {
offset = command.indexOf('"', 1);
} else if (command.startsWith("'")) {
offset = command.indexOf("'", 1);
}
// First word/token is the cmdlet, the rest are the arguments
const index = command.indexOf(" ", offset);
if (index === -1) {
return { env, cmdlet: command.trim(), args: "" };
}
return {
env, cmdlet: command.substring(0, index),
args: command.substring(index + 1).trim(),
}
}
+4 -14
View File
@@ -3,13 +3,6 @@ import log from "electron-log";
import psList from "ps-list";
import { IS_FLATPAK } from "main/constants";
type LinuxOptions = {
// Add the prefix to the command
// eg. command - "./Beat Saber.exe" --no-yeet, prefix - "path/to/proton" run
// = "path/to/proton" run "./Beat Saber.exe" --no-yeet
prefix: string;
};
// Only applied if package as flatpak
type FlatpakOptions = {
// Force to use "flatpak-spawn --host" to run commands outside of the sandbox
@@ -24,11 +17,10 @@ export enum BsmShellLog {
};
interface BsmShellOptions<OptionsType> {
args?: string[];
args?: string[] | string;
options?: OptionsType;
// Look into BsmShellLog values
log?: number;
linux?: LinuxOptions;
flatpak?: FlatpakOptions;
};
@@ -37,7 +29,9 @@ export type BsmExecOptions = BsmShellOptions<cp.ExecOptions>;
function updateCommand(command: string, options: BsmSpawnOptions) {
if (options?.args) {
command += ` ${options.args.join(" ")}`;
command += typeof(options.args) === "string"
? ` ${options.args}`
: ` ${options.args.join(" ")}`;
}
if (process.platform === "linux") {
@@ -45,10 +39,6 @@ function updateCommand(command: string, options: BsmSpawnOptions) {
// All distros should support "bash" by default
options.options.shell = "bash";
if (options.linux?.prefix) {
command = `${options.linux.prefix} ${command}`;
}
if (options?.flatpak?.host) {
const envArgs = (options?.flatpak?.env && options?.options?.env)
&& options.flatpak.env
+14
View File
@@ -39,3 +39,17 @@ ipc.on("bs-mods.beatmods-up", (_, reply) => {
const beatMods = BeatModsApiService.getInstance();
reply(from(beatMods.isUp()));
})
ipc.on("bs-mods.mod-repo.get-repo-list", (_, reply) => {
const beatMods = BeatModsApiService.getInstance();
reply(from(beatMods.getModRepoList()));
})
ipc.on("bs-mods.mod-repo.get-name", (_, reply) => {
const beatMods = BeatModsApiService.getInstance();
reply(from(beatMods.getSelectedModRepoAsync()));
})
ipc.on("bs-mods.mod-repo.select-name", (args, reply) => {
const beatMods = BeatModsApiService.getInstance();
reply(from(beatMods.selectModRepo(args)));
})
-6
View File
@@ -102,12 +102,6 @@ const gotTheLock = app.requestSingleInstanceLock();
const init = () => {
initServicesMustBeInitialized();
if (process.platform === "linux") {
// Properly set up the home path for linux
const homePath = process.env.XDG_DATA_HOME || path.join(process.env.HOME, ".local", "share");
app.setPath("home", homePath);
}
}
if (!gotTheLock) {
@@ -1,13 +1,13 @@
import path from "path";
import { BSVersion } from "shared/bs-version.interface";
import { BsvMapDetail } from "shared/models/maps";
import { BsmLocalMap, BsmLocalMapsProgress, DeleteMapsProgress } from "shared/models/maps/bsm-local-map.interface";
import { BsmLocalMap, BsmLocalMapMetadata, BsmLocalMapsProgress, DeleteMapsProgress } from "shared/models/maps/bsm-local-map.interface";
import { BSLocalVersionService } from "../../bs-local-version.service";
import { InstallationLocationService } from "../../installation-location.service";
import { UtilsService } from "../../utils.service";
import crypto, { BinaryLike } from "crypto";
import { lstatSync } from "fs";
import { copy, createReadStream, ensureDir, pathExists, pathExistsSync, realpath } from "fs-extra";
import { copy, createReadStream, ensureDir, existsSync, pathExists, pathExistsSync, readJson, realpath, writeJson } from "fs-extra";
import { RequestService } from "../../request.service";
import sanitize from "sanitize-filename";
import { DeepLinkService } from "../../deep-link.service";
@@ -31,6 +31,7 @@ import { CustomError } from "shared/models/exceptions/custom-error.class";
import { tryit } from "shared/helpers/error.helpers";
import { BsmZipExtractor } from "main/models/bsm-zip-extractor.class";
import { escapeRegExp } from "../../../../shared/helpers/string.helpers";
import dateFormat from "dateformat";
export class LocalMapsManagerService {
private static instance: LocalMapsManagerService;
@@ -46,6 +47,7 @@ export class LocalMapsManagerService {
public static readonly CUSTOM_LEVELS_FOLDER = "CustomLevels";
public static readonly RELATIVE_MAPS_FOLDER = path.join(LocalMapsManagerService.LEVELS_ROOT_FOLDER, LocalMapsManagerService.CUSTOM_LEVELS_FOLDER);
public static readonly SHARED_MAPS_FOLDER = "SharedMaps";
public static readonly METADATA_FILE = "metadata.json";
private readonly DEEP_LINKS = {
BeatSaver: "beatsaver",
@@ -138,16 +140,21 @@ export class LocalMapsManagerService {
public async loadMapInfoFromPath(mapPath: string): Promise<BsmLocalMap> {
const getUrlsAndReturn = (mapInfo: MapInfo, hash: string, mapPath: string): BsmLocalMap => {
const getUrlsAndReturn = (mapInfo: MapInfo, hash: string, mapPath: string, metadata: BsmLocalMapMetadata): BsmLocalMap => {
const coverUrl = pathToFileURL(path.join(mapPath, mapInfo.coverImageFilename)).href;
const songUrl = pathToFileURL(path.join(mapPath, mapInfo.songFilename)).href;
return { mapInfo, coverUrl, songUrl, hash, path: mapPath, songDetails: this.songDetailsCache.getSongDetails(hash) };
return {
mapInfo, coverUrl, songUrl, hash, path: mapPath,
songDetails: this.songDetailsCache.getSongDetails(hash),
metadata,
};
};
const cachedMapInfos = this.songCache.getMapInfoFromDirname(path.basename(mapPath));
if (cachedMapInfos) {
return getUrlsAndReturn(cachedMapInfos.mapInfo, cachedMapInfos.hash, mapPath);
const metadata = await this.getMetadata(mapPath);
return getUrlsAndReturn(cachedMapInfos.mapInfo, cachedMapInfos.hash, mapPath, metadata);
}
const files = await getFilesInFolder(mapPath);
@@ -166,8 +173,9 @@ export class LocalMapsManagerService {
}
const hash = await this.computeMapHash(mapPath, rawInfoString);
const metadata = await this.getMetadata(mapPath);
return getUrlsAndReturn(mapInfo, hash, mapPath);
return getUrlsAndReturn(mapInfo, hash, mapPath, metadata);
}
private async downloadMapZip(zipUrl: string): Promise<string> {
@@ -462,6 +470,21 @@ export class LocalMapsManagerService {
return localMap;
}
private async getMetadata(mapPath: string): Promise<BsmLocalMapMetadata> {
const metadataPath = path.join(mapPath, LocalMapsManagerService.METADATA_FILE);
if (existsSync(metadataPath)) {
return await readJson(metadataPath) as BsmLocalMapMetadata;
}
// Create the metadata then return it to the user
const metadata: BsmLocalMapMetadata = {
addedDate: dateFormat(new Date(), "yyyy-mm-dd'T'HH:MM:ss.l"),
};
await writeJson(metadataPath, metadata);
return metadata;
}
public async exportMaps(version: BSVersion, maps: BsmLocalMap[], outPath: string): Promise<Observable<Progression>> {
const archive = new Archive(outPath);
@@ -1,14 +1,12 @@
import { LaunchOption } from "shared/models/bs-launch";
import { BSLocalVersionService } from "../bs-local-version.service";
import { ChildProcessWithoutNullStreams, SpawnOptionsWithoutStdio } from "child_process";
import path from "path";
import log from "electron-log";
import { sToMs } from "../../../shared/helpers/time.helpers";
import { LinuxService } from "../linux.service";
import { BsmShellLog, bsmSpawn } from "main/helpers/os.helpers";
import { IS_FLATPAK } from "main/constants";
import { LaunchMods } from "shared/models/bs-launch/launch-option.interface";
import { parseEnvString } from "main/helpers/env.helpers";
export function buildBsLaunchArgs(launchOptions: LaunchOption): string[] {
const launchArgs = [];
@@ -30,10 +28,6 @@ export function buildBsLaunchArgs(launchOptions: LaunchOption): string[] {
launchArgs.push("editor");
}
if (launchOptions.command) {
launchArgs.push(launchOptions.command);
}
return Array.from(new Set(launchArgs).values());
}
@@ -47,20 +41,20 @@ export abstract class AbstractLauncherService {
this.localVersions = BSLocalVersionService.getInstance();
}
private readonly COMMAND_FORMAT = "%command%";
protected launchBeatSaberProcess(options: LaunchBeatSaberOptions): ChildProcessWithoutNullStreams {
const spawnOptions: SpawnOptionsWithoutStdio = {
detached: true,
cwd: options.beatSaberFolderPath,
env: options.env,
};
protected launchBSProcess(bsExePath: string, args: string[], options?: SpawnBsProcessOptions): ChildProcessWithoutNullStreams {
const spawnOptions: SpawnOptionsWithoutStdio = { detached: true, cwd: path.dirname(bsExePath), ...(options || {}) };
if(args.includes("--verbose")){
if (options.args?.includes("--verbose")){
spawnOptions.windowsVerbatimArguments = true;
}
spawnOptions.shell = true; // For windows to spawn properly
return bsmSpawn(`"${bsExePath}"`, {
args, options: spawnOptions, log: BsmShellLog.Command,
linux: { prefix: options?.protonPrefix || "" },
return bsmSpawn(options.cmdlet, {
args: options.args, options: spawnOptions, log: BsmShellLog.Command,
flatpak: {
host: IS_FLATPAK,
env: [
@@ -73,6 +67,7 @@ export abstract class AbstractLauncherService {
"STEAM_COMPAT_CLIENT_INSTALL_PATH",
"STEAM_COMPAT_APP_ID",
"SteamEnv",
"OXR_PARALLEL_VIEWS",
"PROTON_LOG",
"PROTON_LOG_DIR",
],
@@ -80,8 +75,8 @@ export abstract class AbstractLauncherService {
});
}
protected launchBs(bsExePath: string, args: string[], options?: SpawnBsProcessOptions): {process: ChildProcessWithoutNullStreams, exit: Promise<number>} {
const process = this.launchBSProcess(bsExePath, args, options);
protected launchBeatSaber(options: LaunchBeatSaberOptions): {process: ChildProcessWithoutNullStreams, exit: Promise<number>} {
const process = this.launchBeatSaberProcess(options);
let timeoutId: NodeJS.Timeout;
@@ -120,23 +115,13 @@ export abstract class AbstractLauncherService {
return { process, exit };
}
protected injectAdditionalArgsEnvs(
launchOptions: LaunchOption,
env: Record<string, string>
) {
if (!launchOptions.command) {
return;
}
const { command } = launchOptions;
const index = command.indexOf(this.COMMAND_FORMAT);
if (index === -1) {
return;
}
const envString = command.substring(0, index);
log.info("Parsing env string ", `"${envString}"`)
for (const [ key, value ] of Object.entries(parseEnvString(envString))) {
// Launch option helper function
protected mergeEnvVariables(
originalEnv: Record<string, string>,
newEnv: Record<string, string>
): Record<string, string> {
const env = { ...originalEnv };
for (const [ key, value ] of Object.entries(newEnv)) {
log.info(
key in env ? "Overriding" : "Injecting",
`${key}="${value}"`,
@@ -144,13 +129,21 @@ export abstract class AbstractLauncherService {
);
env[key] = value;
}
launchOptions.command = command.substring(index + this.COMMAND_FORMAT.length);
return env;
}
}
export type SpawnBsProcessOptions = {
protonPrefix?: string;
export type LaunchBeatSaberOptions = {
// To be passed to the bsmSpawn helper function
// Can be the Beat Saber exe or wrapper exe (for linux)
cmdlet: string;
env: Record<string, string>;
beatSaberFolderPath: string;
args?: string[]; // Appended to the cmdlet string
// Timeout value (in ms) to unref the Beat Saber process to BSM
unrefAfter?: number;
} & SpawnOptionsWithoutStdio;
}
@@ -9,6 +9,7 @@ import { pathExists } from "fs-extra";
import { AbstractLauncherService, buildBsLaunchArgs } from "./abstract-launcher.service";
import { isProcessRunning } from "../../helpers/os.helpers";
import { CustomError } from "../../../shared/models/exceptions/custom-error.class";
import { parseLaunchOptions } from "main/helpers/launchOptions.helper";
export class OculusLauncherService extends AbstractLauncherService implements StoreLauncherInterface {
@@ -49,19 +50,25 @@ export class OculusLauncherService extends AbstractLauncherService implements St
// Make sure Oculus is running
await this.oculus.startOculus().catch(err => log.error("Error while starting Oculus", err));
const env: Record<string, string> = {
let env: Record<string, string> = {
...process.env,
};
this.injectAdditionalArgsEnvs(launchOptions, env);
const {
env: parsedEnv,
cmdlet, args,
} = parseLaunchOptions(launchOptions.command, {
commandReplacement: exePath,
});
env = this.mergeEnvVariables(env, parsedEnv);
obs.next({type: BSLaunchEvent.BS_LAUNCHING});
// Launch Beat Saber
const bsProcess = this.launchBs(
exePath,
buildBsLaunchArgs(launchOptions),
{ env }
);
const bsProcess = this.launchBeatSaber({
env, cmdlet,
beatSaberFolderPath: bsPath,
args: [ args, ...buildBsLaunchArgs(launchOptions) ]
});
return bsProcess.exit.catch(err => {
throw CustomError.fromError(err, BSLaunchError.BS_EXIT_ERROR);
@@ -6,12 +6,13 @@ import { SteamService } from "../steam.service";
import path from "path";
import { BS_APP_ID, BS_EXECUTABLE, STEAMVR_APP_ID } from "../../constants";
import log from "electron-log";
import { AbstractLauncherService, buildBsLaunchArgs, SpawnBsProcessOptions } from "./abstract-launcher.service";
import { AbstractLauncherService, buildBsLaunchArgs, LaunchBeatSaberOptions } from "./abstract-launcher.service";
import { CustomError } from "../../../shared/models/exceptions/custom-error.class";
import { UtilsService } from "../utils.service";
import { exec, ChildProcessWithoutNullStreams } from "child_process";
import { LaunchMods } from "shared/models/bs-launch/launch-option.interface";
import { app, Event } from "electron";
import { parseLaunchOptions } from "main/helpers/launchOptions.helper";
export class SteamLauncherService extends AbstractLauncherService implements StoreLauncherInterface{
@@ -64,8 +65,8 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto
});
}
protected launchBs(bsExePath: string, args: string[], options?: SpawnBsProcessOptions): {process: ChildProcessWithoutNullStreams, exit: Promise<number>} {
const process = this.launchBSProcess(bsExePath, args, options);
protected launchBeatSaber(options: LaunchBeatSaberOptions): {process: ChildProcessWithoutNullStreams, exit: Promise<number>} {
const process = this.launchBeatSaberProcess(options);
const exit = new Promise<number>((resolve, reject) => {
// Don't remove, useful for debugging!
@@ -146,24 +147,35 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto
const steamPath = await this.steam.getSteamPath();
const env = {
let env: Record<string, string> = {
...process.env,
"SteamAppId": BS_APP_ID,
"SteamOverlayGameId": BS_APP_ID,
"SteamGameId": BS_APP_ID,
};
let protonPrefix = "";
// Linux setup
if (process.platform === "linux") {
const linuxSetup = await this.linux.setupLaunch(
if (launchOptions.admin) {
log.warn("Launching as admin is not supported on Linux! Starting the game as a normal user.");
launchOptions.admin = false;
}
Object.assign(env, await this.linux.buildEnvVariables(
launchOptions, steamPath, bsFolderPath
);
protonPrefix = linuxSetup.protonPrefix;
Object.assign(env, linuxSetup.env);
));
}
this.injectAdditionalArgsEnvs(launchOptions, env);
const {
env: parsedEnv,
cmdlet, args
} = parseLaunchOptions(launchOptions.command, {
commandReplacement: process.platform === "win32"
? `"${bsExePath}"`
: `${await this.linux.getProtonPrefix()} "${bsExePath}"`,
});
env = this.mergeEnvVariables(env, parsedEnv);
const launchArgs = buildBsLaunchArgs(launchOptions);
obs.next({type: BSLaunchEvent.BS_LAUNCHING});
@@ -171,9 +183,12 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto
const spawnOpts = { env, cwd: bsFolderPath };
const launchPromise = !launchOptions.admin ? (
this.launchBs(bsExePath, launchArgs, {
...spawnOpts,
protonPrefix
this.launchBeatSaber({
env, cmdlet,
args: args
? [ args, ...launchArgs ]
: launchArgs,
beatSaberFolderPath: bsFolderPath,
}).exit
) : (
new Promise<number>(resolve => {
@@ -26,6 +26,7 @@ export class InstallationLocationService {
private readonly staticConfig: StaticConfigurationService;
private readonly updateListeners: Set<Listener> = new Set();
private readonly installPath: string;
private _installationDirectory: string;
private constructor() {
@@ -34,6 +35,13 @@ export class InstallationLocationService {
this.staticConfig.$watch(this.STORE_INSTALLATION_PATH_KEY).subscribe(() => {
this.triggerListeners();
});
if (process.platform === "linux") {
this.installPath = process.env.XDG_DATA_HOME
|| path.join(process.env.HOME, ".local", "share");
} else {
this.installPath = app.getPath("home");
}
}
private triggerListeners(): void {
@@ -65,7 +73,7 @@ export class InstallationLocationService {
public defaultInstallationDirectory(): string {
const { result: oldPath } = tryit(() => path.join(app.getPath("documents"), this.INSTALLATION_FOLDER));
const installationDirectory = (oldPath && pathExistsSync(oldPath)) ? app.getPath("documents") : app.getPath("home");
const installationDirectory = (oldPath && pathExistsSync(oldPath)) ? app.getPath("documents") : this.installPath;
return path.join(installationDirectory, this.INSTALLATION_FOLDER);
}
@@ -85,7 +93,7 @@ export class InstallationLocationService {
return app.getPath("documents");
}
return app.getPath("home");
return this.installPath;
};
this._installationDirectory = installParentPath();
+52 -62
View File
@@ -10,6 +10,7 @@ import { BsmShellLog, bsmExec } from "main/helpers/os.helpers";
import { LaunchMods } from "shared/models/bs-launch/launch-option.interface";
import { SteamShortcutData } from "shared/models/steam/shortcut.model";
import { buildBsLaunchArgs } from "./bs-launcher/abstract-launcher.service";
import { parseLaunchOptions } from "main/helpers/launchOptions.helper";
export class LinuxService {
private static instance: LinuxService;
@@ -38,26 +39,11 @@ export class LinuxService {
return path.resolve(sharedFolder, "compatdata");
}
public async setupLaunch(
launchOptions: LaunchOption,
steamPath: string,
bsFolderPath: string
): Promise<{
protonPrefix: string;
env: Record<string, string>;
}> {
if (launchOptions.admin) {
log.warn("Launching as admin is not supported on Linux! Starting the game as a normal user.");
launchOptions.admin = false;
}
public async getProtonPrefix() {
const protonPath = await this.getProtonPath();
return {
protonPrefix: await this.isNixOS()
? `steam-run "${protonPath}" run`
: `"${protonPath}" run`,
env: await this.buildEnvVariables(launchOptions, steamPath, bsFolderPath)
};
return await this.isNixOS()
? `steam-run "${protonPath}" run`
: `"${protonPath}" run`;
}
private async getProtonPath(): Promise<string> {
@@ -81,7 +67,7 @@ export class LinuxService {
return protonPath;
}
private async buildEnvVariables(
public async buildEnvVariables(
launchOptions: LaunchOption,
steamPath: string,
bsFolderPath: string
@@ -105,6 +91,8 @@ export class LinuxService {
"STEAM_COMPAT_APP_ID": BS_APP_ID,
// Run game in steam environment; fixes #585 for unicode song titles
"SteamEnv": "1",
// Fix reflections in Monado
"OXR_PARALLEL_VIEWS": "1",
};
if (launchOptions.launchMods?.includes(LaunchMods.PROTON_LOGS)) {
@@ -174,18 +162,42 @@ export class LinuxService {
// === Shortcuts === //
private getCommand(
protonPrefix: string,
bsFolderPath: string,
env: Record<string, string>,
launchOptions: LaunchOption
): string {
private async getCommand(
launchOptions: LaunchOption,
steamPath: string,
beatSaberFolderPath: string
): Promise<string> {
const protonPrefix = await this.getProtonPrefix();
const launchEnv = await this.buildEnvVariables(
launchOptions, steamPath, beatSaberFolderPath
);
const beatSaberExePath = path.join(beatSaberFolderPath, BS_EXECUTABLE);
const {
env: parsedEnv,
args: parsedArgs,
cmdlet,
} = parseLaunchOptions(launchOptions.command, {
commandReplacement: `${protonPrefix} ${beatSaberExePath}`,
});
const args = buildBsLaunchArgs(launchOptions);
log.debug("Launch arguments:", args, "Parsed arguments:", parsedArgs);
if (parsedArgs) {
args.unshift(parsedArgs);
}
const env = {
...launchEnv, ...parsedEnv,
SteamAppId: BS_APP_ID,
SteamOverlayGameId: BS_APP_ID,
SteamGameId: BS_APP_ID,
};
const envString = Object.entries(env)
.map(([ key, value ]) => `${key}="${value}"`)
.join(" ");
const bsExe = path.join(bsFolderPath, BS_EXECUTABLE);
const args = buildBsLaunchArgs(launchOptions).join(" ");
return `${envString} ${protonPrefix} "${bsExe}" ${args}`;
return `${envString} ${cmdlet} ${args.join(" ")}`;
}
public async createDesktopShortcut(
@@ -194,22 +206,11 @@ export class LinuxService {
icon: string,
launchOptions: LaunchOption,
steamPath: string,
bsFolderPath: string
beatSaberFolderPath: string
): Promise<boolean> {
try {
const {
protonPrefix, env
} = await this.setupLaunch(launchOptions, steamPath, bsFolderPath);
Object.assign(env, {
"SteamAppId": BS_APP_ID,
"SteamOverlayGameId": BS_APP_ID,
"SteamGameId": BS_APP_ID,
});
const command = this.getCommand(
protonPrefix, bsFolderPath,
env, launchOptions
const command = await this.getCommand(
launchOptions, steamPath, beatSaberFolderPath
);
const desktopEntry = [
@@ -217,7 +218,7 @@ export class LinuxService {
"Type=Application",
`Name=${name}`,
`Icon=${icon}`,
`Path=${bsFolderPath}`,
`Path=${beatSaberFolderPath}`,
`Exec=${command}`
].join("\n");
@@ -235,31 +236,20 @@ export class LinuxService {
icon: string,
launchOptions: LaunchOption,
steamPath: string,
bsFolderPath: string
beatSaberFolderPath: string
): Promise<SteamShortcutData> {
const env = await this.buildEnvVariables(
launchOptions, steamPath, bsFolderPath
const protonPath = await this.getProtonPath();
const command = await this.getCommand(
launchOptions, steamPath, beatSaberFolderPath
);
Object.assign(env, {
"SteamAppId": BS_APP_ID,
"SteamOverlayGameId": BS_APP_ID,
"SteamGameId": BS_APP_ID,
});
const protonPrefix = await this.isNixOS()
? "steam-run %command% run"
: "%command% run";
return {
AppName: shortcutName,
Exe: await this.getProtonPath(),
StartDir: bsFolderPath,
Exe: protonPath,
StartDir: beatSaberFolderPath,
icon,
OpenVR: "\x01",
LaunchOptions: this.getCommand(
protonPrefix, bsFolderPath,
env, launchOptions
)
LaunchOptions: command
};
}
@@ -3,18 +3,47 @@ import { BbmFullMod, BbmMod, BbmModVersion, BbmPlatform } from "../../../shared/
import { RequestService } from "../request.service";
import { BsStore } from "../../../shared/models/bs-store.enum";
import log from "electron-log"
import { StaticConfigurationService } from "../static-configuration.service";
import { ModRepo } from "shared/models/mods/repo.model";
export class BeatModsApiService {
private static instance: BeatModsApiService;
private readonly staticConfig: StaticConfigurationService;
private readonly requestService: RequestService;
public readonly MODS_REPO_URL = "https://beatmods.com";
private readonly MODS_REPO_API_URL = `${this.MODS_REPO_URL}/api`;
private static readonly MOD_REPO_LIST:ModRepo[] = [
{
id: "beatmods",
mods_repo_url: "https://beatmods.com",
mods_repo_api_url: "https://beatmods.com/api",
display_name: "BeatMods",
website: "https://beatmods.com"
},
{
id: "beatsabercn",
mods_repo_url: "https://beatmods.bsaber.cn",
mods_repo_api_url: "https://beatmods.bsaber.cn/api",
display_name: "CN中文镜像源",
website: "https://beatmods.bsaber.cn/front/mods"
}
];
private selectedModRepo: ModRepo = BeatModsApiService.MOD_REPO_LIST.find(repo => repo.default) || BeatModsApiService.MOD_REPO_LIST[0];
public getSelectedModRepo(): ModRepo {
return this.selectedModRepo;
}
private readonly versionModsCache = new Map<string, BbmFullMod[]>();
private readonly modsHashCache = new Map<string, BbmModVersion>();
private resetCache(){
this.versionModsCache.clear();
this.modsHashCache.clear();
}
public static getInstance(): BeatModsApiService {
if (!BeatModsApiService.instance) {
BeatModsApiService.instance = new BeatModsApiService();
@@ -23,23 +52,49 @@ export class BeatModsApiService {
}
private constructor() {
this.staticConfig = StaticConfigurationService.getInstance();
this.requestService = RequestService.getInstance();
const repoId = this.staticConfig.get("selected-mod-repo");
if(repoId){
this.selectedModRepo = BeatModsApiService.MOD_REPO_LIST.find(repo => repo.id === repoId) || BeatModsApiService.MOD_REPO_LIST[0];
}
}
public async isUp(): Promise<boolean> {
try {
// The data in status can be dropped
await this.requestService.getJSON<{}>(`${this.MODS_REPO_API_URL}/status`);
await this.requestService.getJSON<{}>(`${this.getSelectedModRepo().mods_repo_api_url}/status`);
return true;
} catch (error) {
log.error("Could not connect to beatmods", error);
log.error(`Could not connect to ${this.selectedModRepo.mods_repo_api_url}`, error);
return false;
}
}
private getVersionModsUrl(version: BSVersion): string {
const platform: BbmPlatform = version.oculus || version.metadata?.store === BsStore.OCULUS ? BbmPlatform.OculusPC : BbmPlatform.SteamPC;
return `${this.MODS_REPO_API_URL}/mods?status=verified&gameVersion=${version.BSVersion}&gameName=BeatSaber&platform=${platform}`;
return `${this.getSelectedModRepo().mods_repo_api_url}/mods?status=verified&gameVersion=${version.BSVersion}&gameName=BeatSaber&platform=${platform}`;
}
public async getModRepoList():Promise<ModRepo[]>{
return BeatModsApiService.MOD_REPO_LIST;
}
public async getSelectedModRepoAsync():Promise<ModRepo>{
return this.getSelectedModRepo();
}
public async selectModRepo(repoId:string): Promise<boolean>{
const selectedRepo = BeatModsApiService.MOD_REPO_LIST.find(repo => repo.id === repoId);
if(!selectedRepo){
return false;
}
this.selectedModRepo = selectedRepo;
this.staticConfig.set("selected-mod-repo", repoId);
this.resetCache()
return true;
}
private updateModsHashCache(mods: BbmModVersion[]): void {
@@ -76,7 +131,7 @@ export class BeatModsApiService {
}
return this.requestService.getJSON<{ modVersions: BbmModVersion[] }>(
`${this.MODS_REPO_API_URL}/hashlookup?hash=${hash}`
`${this.getSelectedModRepo().mods_repo_api_url}/hashlookup?hash=${hash}`
).then(({ data }) => {
this.updateModsHashCache(data?.modVersions ?? []);
return data?.modVersions?.at(0);
@@ -128,7 +128,7 @@ export class BsModsManagerService {
}
private async downloadZip(zipUrl: string): Promise<BsmZipExtractor> {
zipUrl = new URL(zipUrl, this.beatModsApi.MODS_REPO_URL).href;
zipUrl = new URL(zipUrl, this.beatModsApi.getSelectedModRepo().mods_repo_url).href;
log.info("Download mod zip", zipUrl);
@@ -156,39 +156,20 @@ export class BsModsManagerService {
return false;
}
const env: Record<string, string> = {};
const cmd = `"${ipaPath}" "${bsExePath}" ${args.join(" ")}`;
let winePath: string = "";
if (process.platform === "linux") {
const { error: winePathError, result: winePathResult } =
tryit(() => this.linuxService.getWinePath());
if (winePathError) {
log.error(winePathError);
return false;
}
winePath = await this.linuxService.isNixOS()
? `steam-run "${winePathResult}"`
: `"${winePathResult}"`;
const winePrefix = this.linuxService.getWinePrefixPath();
if (!winePrefix) {
throw new CustomError("Could not find BSManager WINEPREFIX path", "no-wineprefix");
}
env.WINEPREFIX = winePrefix;
Object.assign(env, process.env);
const command = await this.getCommand(ipaPath, bsExePath, args);
if (!command) {
return false;
}
return new Promise<boolean>(resolve => {
const processIPA = bsmSpawn(cmd, {
const processIPA = bsmSpawn(command.command, {
log: BsmShellLog.Command | BsmShellLog.EnvVariables,
options: {
cwd: versionPath,
detached: true,
shell: true,
env
env: command.env
},
linux: { prefix: winePath },
});
const timeout = setTimeout(() => {
@@ -214,6 +195,47 @@ export class BsModsManagerService {
});
}
private async getCommand(
ipaPath: string,
beatSaberExePath: string,
args: string[]
): Promise<{
env: Record<string, string>;
command: string;
} | null> {
const command = `"${ipaPath}" "${beatSaberExePath}" ${args.join(" ")}`;
if (process.platform === "win32") {
return {
env: { ...process.env },
command,
};
}
const { error: winePathError, result: winePathResult } =
tryit(() => this.linuxService.getWinePath());
if (winePathError) {
log.error(winePathError);
return null;
}
const winePath = await this.linuxService.isNixOS()
? `steam-run "${winePathResult}"`
: `"${winePathResult}"`;
const winePrefix = this.linuxService.getWinePrefixPath();
if (!winePrefix) {
throw new CustomError("Could not find BSManager WINEPREFIX path", "no-wineprefix");
}
return {
env: {
...process.env,
WINEPREFIX: winePrefix
},
command: `${winePath} ${command}`,
};
}
private getModDownload(modVersion: BbmModVersion): string {
return `/cdn/mod/${modVersion.zipHash}.zip`
}
@@ -330,7 +352,9 @@ export class BsModsManagerService {
const promises = mod.version.contentHashes.map(content => {
const file = content.path.replaceAll("IPA/", "").replaceAll("Data", "Beat Saber_Data");
return deleteFile(path.join(verionPath, file));
return deleteFile(path.join(verionPath, file)).catch(err => {
log.info("Unable to delete IPA file, likely because it has been removed by the --revert command. Here is the error:", err);
});
});
await Promise.all(promises);
+351 -5
View File
@@ -10,7 +10,8 @@ import path from 'path';
import { pipeline } from 'stream/promises';
import sanitize from 'sanitize-filename';
import internal from 'stream';
import { app } from 'electron';
import { app, net } from 'electron';
import { CookieJar } from 'tough-cookie';
export class RequestService {
private static instance: RequestService;
@@ -30,7 +31,93 @@ export class RequestService {
private constructor() {}
private isBeatmodsUrl(url: string): boolean {
const { hostname } = new URL(url);
return hostname === 'beatmods.com' || hostname.endsWith('.beatmods.com');
}
/**
* Uses Electron's Chromium network stack instead of Node's HTTP stack
* to avoid Cloudflare timeout issues that occur with beatmods.com
*/
private async requestWithElectronNet<T = unknown>(url: string): Promise<{ data: T; headers: IncomingHttpHeaders }> {
return new Promise<{ data: T; headers: IncomingHttpHeaders }>((resolve, reject) => {
const request = net.request({
method: 'GET',
url,
headers: this.baseHeaders,
});
let responseBody = Buffer.alloc(0);
let responseHeaders: IncomingHttpHeaders = {};
let isResolved = false;
const timeoutId = setTimeout(() => {
if (!isResolved) {
isResolved = true;
request.abort();
reject(new Error(`Request timeout for ${url}`));
}
}, 15000);
const cleanup = () => {
clearTimeout(timeoutId);
};
request.on('response', (response) => {
responseHeaders = response.headers as IncomingHttpHeaders;
// Validate HTTP status code (got throws on non-2xx by default)
const { statusCode } = response;
if (statusCode < 200 || statusCode >= 300) {
isResolved = true;
cleanup();
reject(new Error(`Request failed with status ${statusCode} for ${url}`));
return;
}
response.on('data', (chunk: Buffer) => {
responseBody = Buffer.concat([responseBody, chunk]);
});
response.on('end', () => {
if (isResolved) return;
isResolved = true;
cleanup();
try {
const bodyText = responseBody.toString('utf-8');
const data = JSON.parse(bodyText) as T;
resolve({ data, headers: responseHeaders });
} catch (parseError) {
reject(new Error(`Failed to parse JSON response from ${url}: ${parseError}`));
}
});
response.on('error', (error) => {
if (isResolved) return;
isResolved = true;
cleanup();
reject(new Error(`Response stream error for ${url}: ${error.message}`));
});
});
request.on('error', (error) => {
if (isResolved) return;
isResolved = true;
cleanup();
reject(new Error(`Network error requesting ${url}: ${error.message}`));
});
request.end();
});
}
public async getJSON<T = unknown>(url: string): Promise<{ data: T; headers: IncomingHttpHeaders }> {
// Node's HTTP stack has Cloudflare compatibility issues with beatmods.com
if (this.isBeatmodsUrl(url)) {
return this.requestWithElectronNet<T>(url);
}
const domain = (new URL(url)).hostname;
const cachedFamily = this.preferredFamilyCache[domain];
if (cachedFamily) {
@@ -57,23 +144,180 @@ export class RequestService {
}
private async requestData<T>(url: string, family: number): Promise<{ data: T; headers: IncomingHttpHeaders }> {
const response = await got(url, {
const cookieJar = new CookieJar();
const first = await got(url, {
// @ts-ignore (ESM is not well supported in this project, We need to move out electron-react-boilerplate, and use Vite)
dnsLookupIpVersion: family,
responseType: "json",
cookieJar,
headers: this.baseHeaders
});
// Follow script redirect to get the JSON
if (first.headers['content-type']?.includes('text/html')) {
const cookieMatch = first.body.match(/document\.cookie="([^"]+)"/);
if (!cookieMatch) {
throw new Error("Cookie not found in JS");
}
const cookieString = cookieMatch[1];
const redirectMatch = first.body.match(/location\.href="([^"]+)"/);
if (!redirectMatch) {
throw new Error("Redirect URL not found");
}
const jsonUrl = redirectMatch[1];
await cookieJar.setCookie(cookieString, jsonUrl);
const second = await got(jsonUrl, {
// @ts-ignore (ESM is not well supported in this project, We need to move out electron-react-boilerplate, and use Vite)
dnsLookupIpVersion: family,
responseType: "json",
cookieJar,
headers: this.baseHeaders
});
return {
data: second.body as T,
headers: second.headers
};
}
return {
data: response.body as T,
headers: response.headers
data: JSON.parse(first.body) as T,
headers: first.headers
};
}
/**
* Uses Electron's Chromium network stack instead of Node's HTTP stack
* to avoid Cloudflare timeout issues that occur with beatmods.com
*/
private downloadFileWithElectronNet(
url: string,
dest: string,
opt?: { preferContentDisposition?: boolean }
): Observable<Progression<string>> {
return new Observable<Progression<string>>((subscriber) => {
const progress: Progression<string> = { current: 0, total: 0 };
let file: WriteStream | undefined;
let isCompleted = false;
const request = net.request({
method: 'GET',
url,
headers: this.baseHeaders,
});
const cleanup = () => {
if (file) {
file.destroy();
}
};
request.on('response', (response) => {
// Validate HTTP status code (got throws on non-2xx by default)
const { statusCode } = response;
if (statusCode < 200 || statusCode >= 300) {
isCompleted = true;
cleanup();
subscriber.error(new Error(`Download failed with status ${statusCode} for ${url}`));
return;
}
const contentLength = response.headers['content-length'];
if (contentLength) {
const length = Array.isArray(contentLength) ? contentLength[0] : contentLength;
progress.total = parseInt(length, 10);
}
const filename = opt?.preferContentDisposition
? this.getFilenameFromContentDisposition(response.headers['content-disposition'] as string)
: null;
if (filename) {
dest = path.join(path.dirname(dest), sanitize(filename));
}
progress.data = dest;
file = createWriteStream(dest);
file.on('error', (error) => {
cleanup();
tryit(() => deleteFileSync(dest));
if (!isCompleted) {
isCompleted = true;
subscriber.error(new Error(`File write error for ${dest}: ${error.message}`));
}
});
response.on('data', (chunk: Buffer) => {
if (file && !file.destroyed) {
progress.current += chunk.length;
subscriber.next(progress);
file.write(chunk);
}
});
response.on('end', () => {
if (isCompleted) return;
if (file && !file.destroyed) {
file.once('finish', () => {
if (isCompleted) return;
isCompleted = true;
subscriber.next(progress);
subscriber.complete();
});
file.end();
} else {
isCompleted = true;
subscriber.next(progress);
subscriber.complete();
}
});
response.on('error', (error) => {
if (isCompleted) return;
isCompleted = true;
cleanup();
tryit(() => deleteFileSync(dest));
subscriber.error(error);
});
});
request.on('error', (error) => {
if (isCompleted) return;
isCompleted = true;
cleanup();
tryit(() => deleteFileSync(dest));
subscriber.error(error);
});
request.end();
return () => {
request.abort();
cleanup();
};
}).pipe(
tap({ error: (e) => log.error(e, url, dest) }),
shareReplay(1)
);
}
public downloadFile(
url: string,
dest: string,
opt?: { preferContentDisposition?: boolean }
): Observable<Progression<string>> {
// Node's HTTP stack has Cloudflare compatibility issues with beatmods.com
if (this.isBeatmodsUrl(url)) {
return this.downloadFileWithElectronNet(url, dest, opt);
}
return new Observable<Progression<string>>((subscriber) => {
const progress: Progression<string> = { current: 0, total: 0 };
@@ -150,10 +394,112 @@ export class RequestService {
);
}
/**
* Uses Electron's Chromium network stack instead of Node's HTTP stack
* to avoid Cloudflare timeout issues that occur with beatmods.com
*/
private downloadBufferWithElectronNet(
url: string,
options?: got.GotOptions<null>
): Observable<Progression<Buffer, IncomingMessage>> {
return new Observable<Progression<Buffer, IncomingMessage>>((subscriber) => {
const progress: Progression<Buffer, IncomingMessage> = {
current: 0,
total: 0,
data: null,
};
// Convert headers to the format expected by electron.net (string | string[])
const electronHeaders: Record<string, string | string[]> = { ...this.baseHeaders };
if (options?.headers) {
for (const [key, value] of Object.entries(options.headers)) {
if (typeof value === 'string' || Array.isArray(value)) {
electronHeaders[key] = value;
} else if (value != null) {
electronHeaders[key] = String(value);
}
}
}
let data = Buffer.alloc(0);
let responseHeaders: IncomingHttpHeaders = {};
let isCompleted = false;
const request = net.request({
method: 'GET',
url,
headers: electronHeaders,
});
request.on('response', (response) => {
// Validate HTTP status code (got throws on non-2xx by default)
const { statusCode } = response;
if (statusCode < 200 || statusCode >= 300) {
isCompleted = true;
subscriber.error(new Error(`Download failed with status ${statusCode} for ${url}`));
return;
}
const contentLength = response.headers['content-length'];
if (contentLength) {
const length = Array.isArray(contentLength) ? contentLength[0] : contentLength;
progress.total = parseInt(length, 10);
}
responseHeaders = response.headers as IncomingHttpHeaders;
response.on('data', (chunk: Buffer) => {
data = Buffer.concat([data, chunk]);
progress.current = data.length;
subscriber.next(progress);
});
response.on('end', () => {
if (isCompleted) return;
isCompleted = true;
progress.data = data;
// Required to maintain API compatibility with got-based implementation
const mockResponse = {
headers: responseHeaders,
} as IncomingMessage;
progress.extra = mockResponse;
subscriber.next(progress);
subscriber.complete();
});
response.on('error', (error) => {
if (isCompleted) return;
isCompleted = true;
subscriber.error(error);
});
});
request.on('error', (error) => {
if (isCompleted) return;
isCompleted = true;
subscriber.error(error);
});
request.end();
return () => {
request.abort();
};
}).pipe(
tap({ error: (e) => log.error(e, url) }),
shareReplay(1)
);
}
public downloadBuffer(
url: string,
options?: got.GotOptions<null>
): Observable<Progression<Buffer, IncomingMessage>> {
// Node's HTTP stack has Cloudflare compatibility issues with beatmods.com
if (this.isBeatmodsUrl(url)) {
return this.downloadBufferWithElectronNet(url, options);
}
return new Observable<Progression<Buffer, IncomingMessage>>((subscriber) => {
const progress: Progression<Buffer, IncomingMessage> = {
current: 0,
@@ -92,6 +92,7 @@ export interface StaticConfigKeyValues {
"use-system-proxy": boolean;
"last-version-launched": BSVersion;
"auto-update": AutoUpdate;
"selected-mod-repo": string;
// Linux Specific static configs
"proton-folder": string;
@@ -74,9 +74,9 @@ export function Modal() {
return (
<AnimatePresence>
{currentModal ? <motion.span key={crypto.randomUUID()} onClick={onOverlayClicked} className="fixed size-full bg-black z-[90]" initial={{ opacity: 0 }} animate={{ opacity: currentModal && 0.6 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }} /> : undefined}
{currentModal ? <motion.span key="modal-overlay" onClick={onOverlayClicked} className="fixed size-full bg-black z-[90]" initial={{ opacity: 0 }} animate={{ opacity: currentModal && 0.6 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }} /> : undefined}
{modals?.map(modal => (
<motion.div key={crypto.randomUUID()} className="fixed z-[90] top-1/2 left-1/2" initial={{ y: "100vh", x: "-50%" }} animate={{y: "-50%", scale: modal === currentModal ? 1 : 0, opacity: modal === currentModal ? 1 : 0, display: modal === currentModal ? "block" : ["block", "none"]}} exit={{ y: "100vh" }}>
<motion.div key={modal.id} className="fixed z-[90] top-1/2 left-1/2" initial={{ y: "100vh", x: "-50%" }} animate={{y: "-50%", scale: modal === currentModal ? 1 : 0, opacity: modal === currentModal ? 1 : 0, display: modal === currentModal ? "block" : ["block", "none"]}} exit={{ y: "100vh" }}>
{renderModal(modal)}
</motion.div>
))}
@@ -1,4 +1,4 @@
import { forwardRef, LegacyRef, useImperativeHandle, useRef, useState } from "react";
import { forwardRef, LegacyRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { BsmIconType, BsmIcon } from "../svgs/bsm-icon.component";
import { BsmButton, BsmButtonType } from "./bsm-button.component";
import { useTranslation } from "renderer/hooks/use-translation.hook";
@@ -32,14 +32,25 @@ type Props = {
children?: JSX.Element;
text?: string;
textClassName?: string;
maxVisibleItems?: number;
};
export const BsmDropdownButton = forwardRef(({ className, classNames, buttonColor, items, align, withBar = true, icon = "settings", buttonClassName, menuTranslationY, children, text, textClassName }: Props, fowardRed) => {
export const BsmDropdownButton = forwardRef(({ className, classNames, buttonColor, items, align, withBar = true, icon = "settings", buttonClassName, menuTranslationY, children, text, textClassName, maxVisibleItems }: Props, fowardRed) => {
const [expanded, setExpanded] = useState(false);
const t = useTranslation();
const ref = useRef(fowardRed);
useClickOutside(ref, () => setExpanded(false));
const itemRef = useRef<HTMLDivElement>(null);
const [itemHeight, setItemHeight] = useState<number>();
const maxHeight = itemHeight && maxVisibleItems ? itemHeight * maxVisibleItems + 8 : undefined;
useEffect(() => {
if (itemRef.current) {
setItemHeight(itemRef.current.offsetHeight);
}
}, [items]);
useImperativeHandle(
fowardRed,
() => ({
@@ -73,13 +84,13 @@ export const BsmDropdownButton = forwardRef(({ className, classNames, buttonColo
})();
return (
<div ref={ref as unknown as LegacyRef<HTMLDivElement>} className={cn(className, classNames?.mainContainer)}>
<div ref={ref as unknown as LegacyRef<HTMLDivElement>} className={cn(className, classNames?.mainContainer)} >
<BsmButton onClick={() => setExpanded(!expanded)} className={cn(buttonClassName ?? defaultButtonClassName, classNames?.button)} icon={icon} active={expanded} textClassName={textClassName} onClickOutside={handleClickOutside} withBar={withBar} text={text} typeColor={buttonColor} iconClassName={classNames?.iconClassName}/>
<div className={cn(`py-1 w-fit absolute cursor-pointer top-[calc(100%-4px)] rounded-md bg-inherit text-sm text-gray-800 dark:text-gray-200 shadow-md shadow-black transition-[scale] duration-150 ease-in-out ${alignClass}`, classNames?.itemsContainer)} style={{ scale: expanded ? "1" : "0", translate: `0 ${menuTranslationY}` }}>
<div className={cn(`py-1 w-fit absolute cursor-pointer top-[calc(100%-4px)] rounded-md bg-inherit text-sm text-gray-800 dark:text-gray-200 shadow-md shadow-black transition-[scale] duration-150 ease-in-out ${alignClass} overflow-y-auto scrollbar-thin `, classNames?.itemsContainer)} style={{ scale: expanded ? "1" : "0", translate: `0 ${menuTranslationY}`, maxHeight}}>
{items?.map(
i =>
(i, index) =>
i && (
<div key={crypto.randomUUID()} onClick={() => { setExpanded(() => false); i.onClick?.()}} className="flex w-full px-3 py-2 hover:backdrop-brightness-150">
<div ref={index === 0 ? itemRef : undefined} key={crypto.randomUUID()} onClick={() => { setExpanded(() => false); i.onClick?.()}} className="flex w-full px-3 py-2 hover:backdrop-brightness-150">
{i.icon && <BsmIcon icon={i.icon} className="h-5 w-5 mr-1 text-inherit" />}
<span className="w-max">{t(i.text)}</span>
</div>
@@ -23,6 +23,7 @@ import { Dropzone } from "renderer/components/shared/dropzone.component";
import { ModsGridStatus } from "shared/models/mods/mod-ipc.model";
import { BsmLink } from "renderer/components/shared/bsm-link.component";
import { DISCORD_URL, GITHUB_URL } from "shared/constants";
import { ModRepo } from "shared/models/mods/repo.model";
export type ModsSlideRef = {
loadMods: () => Promise<void>;
@@ -55,6 +56,8 @@ export const ModsSlide = forwardRef<ModsSlideRef, Props>(({ version, isActive, o
const downloadRef = useRef(null);
const [downloadWith, setDownloadWidth] = useState(0);
const [selectedModRepo, setSelectedModRepo] = useState(null as ModRepo)
const modsToCategoryMap = (mods: BbmFullMod[]): Map<BbmCategories, BbmFullMod[]> => {
if (!mods) {
return new Map<BbmCategories, BbmFullMod[]>();
@@ -218,10 +221,15 @@ export const ModsSlide = forwardRef<ModsSlideRef, Props>(({ version, isActive, o
loadMods
}), [version]);
useEffect(()=>{
modsManager.getSelectedModRepo().then(repo=>setSelectedModRepo(repo));
}, []);
useEffect(() => {
let isCancelled = false;
if(!isActive){
return noop();
return noop;
}
ensureDisclaimerAccepted().then(async canLoad => {
@@ -229,13 +237,30 @@ export const ModsSlide = forwardRef<ModsSlideRef, Props>(({ version, isActive, o
return onDisclamerDecline?.();
}
if (isCancelled) return;
const status = await modsManager.getModsGridStatus();
if (isCancelled) return;
setGridStatus(() => status);
loadMods();
if (status !== ModsGridStatus.OK) {
return;
}
modsManager.getVersionModsState(version).then(({ available, installed }) => {
if (isCancelled) return;
const defaultMods = installed?.length ? [] : available.filter(m => m.mod.category === BbmCategories.Core || m.mod.category === BbmCategories.Essential);
setModsAvailable(() => modsToCategoryMap(available));
setModsSelected(available.filter(m => m.mod.category === BbmCategories.Core || defaultMods.some(d => m.mod.name.toLowerCase() === d.mod.name.toLowerCase()) || installed.some(i => m.mod.id === i.mod.id)));
setModsInstalled(modsToCategoryMap(installed));
});
});
return () => {
isCancelled = true;
setMoreInfoMod(null);
setModsAvailable(null);
setModsInstalled(null);
@@ -266,6 +291,18 @@ export const ModsSlide = forwardRef<ModsSlideRef, Props>(({ version, isActive, o
const renderStatus = () => {
if (gridStatus === ModsGridStatus.BEATMODS_DOWN) {
if(selectedModRepo && selectedModRepo.id !== "beatmods"){
return <ModStatus image={BeatConflictImg}>
<span className="text-xl text-center px-2 mt-3 italic">
{
t("pages.version-viewer.mods.notifications.third-party-mod-source-not-avaliable.description",
{name:selectedModRepo ? selectedModRepo.display_name : "null"}
)
}
</span>
</ModStatus>
}
return <ModStatus image={BeatConflictImg}>
<span className="text-xl text-center px-2 mt-3 italic">
{te("pages.version-viewer.mods.status.beatmods-down", {links: (<>
+2 -2
View File
@@ -12,9 +12,9 @@ export function useClickOutside(ref: MutableRefObject<any>, handler: ComponentPr
}
};
document.addEventListener("click", handleClickOutside);
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
document.removeEventListener("mousedown", handleClickOutside);
};
}, [ref]);
}
+34 -2
View File
@@ -49,6 +49,8 @@ import { AutoUpdaterService } from "renderer/services/auto-updater.service";
import { OculusDownloaderService } from "renderer/services/bs-version-download/oculus-downloader.service";
import { DISCORD_URL } from "shared/constants";
import { AutoUpdate } from "shared/models/config";
import { BsModsManagerService } from "renderer/services/bs-mods-manager.service";
import { ModRepo } from "shared/models/mods/repo.model";
export function SettingsPage() {
@@ -69,6 +71,7 @@ export function SettingsPage() {
const versionLinker = useService(VersionFolderLinkerService);
const staticConfig = useService(StaticConfigurationService);
const installationLocationService = useService(InstallationLocationService);
const bsModManagerService = useService(BsModsManagerService);
const autoUpdater = useService(AutoUpdaterService);
const { firstColor, secondColor } = useThemeColor();
@@ -100,6 +103,8 @@ export function SettingsPage() {
const [playlistsDeepLinkEnabled, setPlaylistsDeepLinkEnabled] = useState(false);
const [modelsDeepLinkEnabled, setModelsDeepLinkEnabled] = useState(false);
const [hasDownloaderSession, setHasDownloaderSession] = useState(false);
const [modRepoList, setModRepoList] = useState([] as ModRepo[]);
const [modRepo, setModRepo] = useState("");
const appVersion = useObservable(() => autoUpdater.getAppVersion());
useEffect(() => {
@@ -109,6 +114,15 @@ export function SettingsPage() {
playlistsManager.isDeepLinksEnabled().then(enabled => setPlaylistsDeepLinkEnabled(() => enabled));
modelsManager.isDeepLinksEnabled().then(enabled => setModelsDeepLinkEnabled(() => enabled));
bsModManagerService.getModRepoList().then(list =>{
setModRepoList(list);
bsModManagerService.getSelectedModRepo().then(repo => {
setModRepo(repo.id);
})
});
staticConfig.get("proton-folder").then(setProtonFolder);
}, []);
@@ -157,6 +171,13 @@ export function SettingsPage() {
i18nService.setLanguage(item.value);
};
const handleChangeModRepo = (repo: RadioItem<string>) => {
bsModManagerService.selectModRepo(repo.value).then(result=>{
if(result){
setModRepo(repo.value)
}
})
}
const setDefaultProtonFolder = async () => {
if (!progressBarService.require()) {
return;
@@ -247,8 +268,8 @@ export function SettingsPage() {
const openSupportPage = () => linkOpener.open("https://www.patreon.com/bsmanager");
const openGithub = () => linkOpener.open("https://github.com/Zagrios/bs-manager");
const openReportBug = () => linkOpener.open("https://github.com/Zagrios/bs-manager/issues/new?assignees=Zagrios&labels=bug&template=-bug--bug-report.md&title=%5BBUG%5D+%3A+");
const openRequestFeatures = () => linkOpener.open("https://github.com/Zagrios/bs-manager/issues/new?assignees=Zagrios&labels=enhancement&template=-feat---feature-request.md&title=%5BFEAT.%5D+%3A+");
const openReportBug = () => linkOpener.open("https://github.com/Zagrios/bs-manager/issues/new?template=1-bug-report.yaml");
const openRequestFeatures = () => linkOpener.open("https://github.com/Zagrios/bs-manager/issues/new?template=2-feature-request.yaml");
const openDiscord = () => linkOpener.open(DISCORD_URL);
const openTwitter = () => linkOpener.open("https://twitter.com/BSManager_");
@@ -508,6 +529,17 @@ export function SettingsPage() {
<SettingRadioArray items={languagesItems} selectedItemValue={languageSelected} onItemSelected={handleChangeLanguage} columnCount={2} />
</SettingContainer>
<SettingContainer title="pages.settings.mod-repos.title" description="pages.settings.mod-repos.description">
<SettingRadioArray items={modRepoList.map((repo,index)=>({
id:index,
value: repo.id,
text: repo.display_name,
icon: repo.website ?
<BsmButton onClick={()=>linkOpener.open(repo.website, false)} className="px-2 font-bold italic text-sm rounded-md" text="pages.settings.mod-repos.website" withBar={false} />
: null
}))} selectedItemValue={modRepo} onItemSelected={handleChangeModRepo} />
</SettingContainer>
<SettingContainer title="pages.settings.patreon.title" description="pages.settings.patreon.description">
<div className="flex gap-2">
<BsmButton color="#ef4444" className="flex w-fit rounded-md h-8 px-2 font-bold py-1 whitespace-nowrap !text-white" text="pages.settings.patreon.buttons.support" withBar={false} onClick={openSupportPage} />
@@ -1,4 +1,4 @@
import { Observable, BehaviorSubject, throwError, of, lastValueFrom } from "rxjs";
import { Observable, BehaviorSubject, throwError, of, lastValueFrom, from } from "rxjs";
import { catchError, map, tap } from "rxjs/operators";
import { BSVersion } from "shared/bs-version.interface";
import { IpcService } from "./ipc.service";
@@ -10,6 +10,7 @@ import { BbmFullMod, BbmModVersion } from "shared/models/mods/mod.interface";
import { logRenderError } from "renderer";
import { ModsGridStatus } from "shared/models/mods/mod-ipc.model";
import { LinuxService } from "./linux.service";
import { ModRepo } from "shared/models/mods/repo.model";
export class BsModsManagerService {
private static instance: BsModsManagerService;
@@ -226,4 +227,18 @@ export class BsModsManagerService {
return ModsGridStatus.OK;
}
public async getModRepoList(): Promise<ModRepo[]> {
return lastValueFrom(this.ipcService.sendV2("bs-mods.mod-repo.get-repo-list")).catch(() => [] as ModRepo[]);
}
public async getSelectedModRepo(): Promise<ModRepo> {
return lastValueFrom(this.ipcService.sendV2("bs-mods.mod-repo.get-name").pipe(
catchError(() => from(this.getModRepoList()).pipe(map(list => list.find(repo => repo.default) || list[0])))
));
}
public async selectModRepo(name:string): Promise<boolean> {
return lastValueFrom(this.ipcService.sendV2("bs-mods.mod-repo.select-name", name)).catch(() => false);
}
}
+2 -2
View File
@@ -24,7 +24,7 @@ export class ModalService {
const promise = new Promise<ModalResponse<T>>(resolve => {
resolver = resolve as (value: ModalResponse | PromiseLike<ModalResponse>) => void;
});
const modalObj = {modal: modal as ModalComponent, resolver, options};
const modalObj = {id: crypto.randomUUID(), modal: modal as ModalComponent, resolver, options};
this._modalToShow$.next([...this._modalToShow$.getValue(), modalObj]);
promise.then(() => {
@@ -41,7 +41,7 @@ export class ModalService {
export type ModalOptions<T = unknown> = { readonly data?: T, readonly noStyle?: boolean, readonly closable?: boolean }
export type ModalComponent<Return = unknown, Receive = unknown> = ({ resolver, options }: { readonly resolver: (x: ModalResponse<Return>) => void; readonly options?: ModalOptions<Receive> }) => JSX.Element;
export type ModalObject = {modal: ModalComponent, resolver: (value: ModalResponse | PromiseLike<ModalResponse>) => void, options: ModalOptions};
export type ModalObject = {id: string, modal: ModalComponent, resolver: (value: ModalResponse | PromiseLike<ModalResponse>) => void, options: ModalOptions};
export const enum ModalExitCode {
NO_CHOICE = -1,
+4
View File
@@ -22,6 +22,7 @@ import { StaticConfigGetIpcRequestResponse, StaticConfigKeys, StaticConfigSetIpc
import { BbmFullMod, BbmModVersion, ExternalMod } from "../mods/mod.interface";
import { OculusDownloadInfo } from "main/services/bs-version-download/bs-oculus-downloader.service";
import { UpdateInfo } from "electron-updater";
import { ModRepo } from "../mods/repo.model";
export type IpcReplier<T> = (data: Observable<T>) => void;
@@ -90,6 +91,9 @@ export interface IpcChannelMapping {
"bs-mods.uninstall-mods": { request: { mods: BbmFullMod[]; version: BSVersion }, response: Progression };
"bs-mods.uninstall-all-mods": { request: BSVersion, response: Progression };
"bs-mods.beatmods-up": { request: void, response: boolean };
"bs-mods.mod-repo.get-repo-list": { request: void, response: ModRepo[]};
"bs-mods.mod-repo.get-name": { request: void, response: ModRepo};
"bs-mods.mod-repo.select-name": { request: string, response: boolean};
/* ** bs-playlist-ipcs ** */
"one-click-install-playlist": { request: string, response: Progression<DownloadPlaylistProgressionData> };
@@ -8,6 +8,12 @@ export interface BsmLocalMap {
mapInfo: MapInfo;
songDetails?: SongDetails;
path: string;
metadata?: BsmLocalMapMetadata;
}
export interface BsmLocalMapMetadata {
// Date of download or import
addedDate: string;
}
export interface BsmLocalMapsProgress {
+7
View File
@@ -35,6 +35,13 @@ export const mapSorter = new Sorter<BsmLocalMap>({
return !map2.songDetails ? Comparison.GREATER : map1.songDetails.uploadedAt - map2.songDetails.uploadedAt;
},
"added-date": (map1, map2) => {
if (!map1.metadata) {
return map2.metadata ? Comparison.LESSER : Comparison.EQUAL;
}
return !map2.metadata ? Comparison.GREATER : map1.metadata.addedDate.localeCompare(map2.metadata.addedDate);
}
},
tiebreak: sortName,
defaultKey: "name"
+8
View File
@@ -0,0 +1,8 @@
export interface ModRepo {
id: string
mods_repo_url: string
mods_repo_api_url: string
display_name: string
website?: string
default?: boolean
}