mirror of
https://github.com/Zagrios/bs-manager.git
synced 2026-07-03 14:08:25 +02:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74b91ee277 | |||
| 756cccf85c | |||
| 1591e32b4f | |||
| 57ca031e70 | |||
| 49ef8203c8 | |||
| d9a68e91ca | |||
| 2a4e775cec | |||
| 58529a2ea2 | |||
| ff028c77a1 | |||
| 8edfea66f2 | |||
| 378fa1150e | |||
| 92a1232f85 | |||
| 25047b6c0a | |||
| 2512165898 | |||
| bf1d9408d8 | |||
| b076850f63 | |||
| ff8932ea99 | |||
| 6d19d21116 | |||
| 58f8d8e243 | |||
| 06bb2fe416 | |||
| 58211271e6 | |||
| e3dc280d34 | |||
| 1cd3aa762a | |||
| 94f682b6a2 | |||
| ecb1b57c9b | |||
| 21fdea9c35 | |||
| 2073d8ffa4 | |||
| c9c24479d3 | |||
| cf0921051b | |||
| 526a3c3ee8 | |||
| 2003507713 | |||
| 674aab3b4d | |||
| 09c9f19dcb | |||
| 1cb3cc09a9 | |||
| e327074459 | |||
| 56f80f14b2 | |||
| a8fc1e1a08 | |||
| d09ca56eeb | |||
| 1df7045683 | |||
| fd2795a1f7 | |||
| 47ff527f4d | |||
| c6a3a1249c | |||
| 3f1e3e27ba | |||
| 459b7797d0 | |||
| cd89072c4c | |||
| 092f10a6ab | |||
| 4f43f3b833 | |||
| 4a90ac466a | |||
| 5ff8473a85 | |||
| a5784e9b6e | |||
| e62b79222b | |||
| f3359cb43d | |||
| 390167f0b5 |
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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の継続的な応援にご協力ください!",
|
||||
|
||||
@@ -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의 지속적인 응원을 부탁드립니다!",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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。",
|
||||
|
||||
@@ -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。",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Generated
+1569
-788
File diff suppressed because it is too large
Load Diff
+21
-16
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+332
-10
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)));
|
||||
})
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: (<>
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user