Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
7900adb
feat: initial pp module manager design
storycraft Jun 23, 2026
bfaf09e
chore: update confusing messsage
storycraft Jun 23, 2026
2d89fe0
feat: update on pp calc changes
storycraft Jun 23, 2026
da85477
feat: update config entry and fix confusing error message
storycraft Jun 23, 2026
15b87f9
fix: apply loose condition
storycraft Jun 23, 2026
033a5bb
fix: split package names, fix downloading
storycraft Jun 23, 2026
54dc9ec
chore: fallback to internal on fail
storycraft Jun 23, 2026
fbaa474
chore: update gitignore
storycraft Jun 23, 2026
b13786f
chore: update error message
storycraft Jun 23, 2026
9f09339
feat: ugly hack
storycraft Jun 23, 2026
20c7ef3
fix: always remove archive
storycraft Jun 23, 2026
9d8b6d6
fix: export style
storycraft Jun 24, 2026
8c43271
feat: reject incompatible version
storycraft Jun 24, 2026
39aa24f
chore: use cache path
storycraft Jun 26, 2026
9c846e3
feat: download verify
storycraft Jun 26, 2026
154fb0f
chore: update gitignore
storycraft Jun 26, 2026
1b0f0c8
feat: initial pp module manager design
storycraft Jun 23, 2026
2c989fb
chore: update confusing messsage
storycraft Jun 23, 2026
bbfe5f7
feat: update on pp calc changes
storycraft Jun 23, 2026
b2e3799
feat: update config entry and fix confusing error message
storycraft Jun 23, 2026
c778d10
fix: apply loose condition
storycraft Jun 23, 2026
c7f7ebc
fix: split package names, fix downloading
storycraft Jun 23, 2026
b917a6d
chore: fallback to internal on fail
storycraft Jun 23, 2026
6446cb7
chore: update gitignore
storycraft Jun 23, 2026
8328ae6
chore: update error message
storycraft Jun 23, 2026
73e68d7
feat: ugly hack
storycraft Jun 23, 2026
523b469
fix: always remove archive
storycraft Jun 23, 2026
efe7137
fix: export style
storycraft Jun 24, 2026
a30094a
feat: reject incompatible version
storycraft Jun 24, 2026
281518f
chore: use cache path
storycraft Jun 26, 2026
caf322d
feat: download verify
storycraft Jun 26, 2026
7a0a27a
chore: update gitignore
storycraft Jun 26, 2026
22ffb96
Merge branch 'feat/pp-switcher' of https://github.com/tosuapp/tosu in…
storycraft Jun 26, 2026
45fbc80
chore: accept closure instead to reduce complexity
storycraft Jun 28, 2026
83a3467
chore: throw error in `onlinePpRegistry.fetch`
storycraft Jun 28, 2026
ed760a4
Merge branch 'master' into feat/pp-switcher
storycraft Jun 28, 2026
bf5f265
chore: use catalog for semver
storycraft Jun 28, 2026
88722fd
Merge branch 'master' into feat/pp-switcher
storycraft Jul 3, 2026
53eeb8b
chore: standardize log message
storycraft Jul 3, 2026
f974b46
chore: reload only when ppChannel changed
storycraft Jul 3, 2026
43067ef
Merge branch 'master' into feat/pp-switcher
storycraft Jul 9, 2026
3132a7c
chore: set type: module in @tosu/pp-module-loader
storycraft Jul 9, 2026
676ef60
fix: merged type
storycraft Jul 9, 2026
24e12be
feat: pp calculator selection in dashboard
storycraft Jul 10, 2026
0e9be69
Merge branch 'master' into feat/pp-switcher
storycraft Jul 10, 2026
1b5cd77
Merge branch 'master' into feat/pp-switcher
storycraft Jul 16, 2026
6e114f2
Merge branch 'feat/pp-switcher' of https://github.com/tosuapp/tosu in…
storycraft Jul 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/common/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ const defaultSchema: ConfigSchema = {
binding: 'CALCULATE_PP',
default: true
},
ppChannel: {
binding: 'PP_CHANNEL',
default: 'latest'
},
enableKeyOverlay: {
binding: 'ENABLE_KEY_OVERLAY',
default: true
Expand Down
6 changes: 6 additions & 0 deletions packages/common/utils/config.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ export interface Config {
binding: 'CALCULATE_PP';
};

// The channel to use for calculating performance points data.
ppChannel: {
type: string;
binding: 'PP_CHANNEL';
};

// Whether tosu should read osu!'s built-in key overlay data.
enableKeyOverlay: {
type: boolean;
Expand Down
18 changes: 18 additions & 0 deletions packages/pp-module-loader/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "@tosu/pp-module-loader",
"version": "0.1.0",
"type": "module",
"description": "Load and manage pp calculators",
"main": "src/index.ts",
"scripts": {},
"dependencies": {
"@tosu/common": "workspace:*",
"@tosuapp/lazer-calculator-prebuilt": "0.5.0-20260715-main.0",
"semver": "catalog:",
"tar": "^7.5.16"
},
"devDependencies": {
"@types/node": "^24.10.4",
"@types/semver": "^7.7.1"
}
}
58 changes: 58 additions & 0 deletions packages/pp-module-loader/src/downloader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { downloadFile, getCachePath, verifyDownload } from '@tosu/common';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as tar from 'tar';

import { getPrebuiltPackageName } from './package';
import type { NpmPackageDist } from './registry/types';

export async function downloadCalculator(
version: string,
fetchDist: () => Promise<NpmPackageDist> | NpmPackageDist
): Promise<string> {
const packageName = getPrebuiltPackageName();

const calculatorRootPath = getCalculatorPath();
const folderPath = path.join(
calculatorRootPath,
`${packageName}-${version}`
);
const archivePath = path.join(
calculatorRootPath,
`${packageName}-${version}.tgz`
);

if (await fs.stat(folderPath).catch(() => false)) {
return folderPath;
}

const dist = await fetchDist();
await fs.mkdir(calculatorRootPath, { recursive: true });
try {
await downloadFile(dist.tarball, archivePath);

if (!(await verifyDownload(`sha1:${dist.shasum}`, archivePath))) {
await fs.rm(archivePath);
throw new Error('Download verification failed.');
}

await fs.mkdir(folderPath, { recursive: true });
Comment thread
cyperdark marked this conversation as resolved.
await tar.x({
file: archivePath,
cwd: folderPath,
strip: 1
});
Comment on lines +40 to +44

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yesterday i had to unpack .tar.gz & .tgz, for that i used system tar with calling execFile, and as far as i know it's installed by default on windows and linux

import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import path from 'path';


async function extractTar(file_path: string, destination: string, args: string[]): Promise<string | Error> {
	try {
		const tgz_path = path.resolve(file_path);
		const dest_path = path.resolve(destination);

		const result = await promisify(execFile)('tar', ['-xzf', `${tgz_path}`, '-C', `${dest_path}`, ...args]);
		if (result.stderr) return new Error(result.stderr);

		return result.stdout;
	} catch (error: any) {
		return error.code === 'ENOENT'
			? new Error('The "tar" command is not found in your system PATH. Please ensure it is installed.')
			: error;
	};
};

example of usage:

const extract = await extractTar(file_path, cache_directory, ['--strip-components=2']);
if (extract.status == 'error') {
	console.error(`extracting failed`, extract);
	process.exit(1);
};

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No idea if it's good idea to rely on os command

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

} catch (err) {
await fs.rm(folderPath, { recursive: true, force: true });

throw err;
} finally {
await fs.rm(archivePath, { force: true });
}

return folderPath;
}

function getCalculatorPath(): string {
return path.join(getCachePath(), 'calculators');
}
101 changes: 101 additions & 0 deletions packages/pp-module-loader/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { wLogger } from '@tosu/common';
import * as internalCalculator from '@tosuapp/lazer-calculator-prebuilt';
import EventEmitter from 'node:events';
import { resolve } from 'node:path';

import { downloadCalculator } from './downloader';
import { isCompatibleVersion } from './package';
import { onlinePpRegistry } from './registry';

export type LazerCalculator =
typeof import('@tosuapp/lazer-calculator-prebuilt');

export type PpModule =
| { type: 'internal' }
| { type: 'dist-tag'; tag: string }
| { type: 'release'; version: string }
| { type: 'local'; path: string };

let currentCalculator = internalCalculator;

const ppModuleManager = {
events: new EventEmitter<{ changed: [] }>(),

get current(): LazerCalculator {
return currentCalculator;
},

async load(module: PpModule): Promise<void> {
try {
currentCalculator = await resolveCalculator(module);
wLogger.info('Successfully loaded external pp calculator.');
} catch (err) {
wLogger.error(
'Failed to load external pp calculator. Falling back to internal pp calculator.'
);
wLogger.debug('Failed to load pp calculator:', err);
currentCalculator = internalCalculator;
}

this.events.emit('changed');
}
};

async function resolveCalculator(module: PpModule): Promise<LazerCalculator> {
switch (module.type) {
case 'internal': {
wLogger.info('Using internal pp calculator');
return internalCalculator;
}

case 'dist-tag': {
wLogger.info(
`Loading external pp calculator. channel: ${module.tag}`
);

const pkg = await onlinePpRegistry.fetch(module.tag);
if (!isCompatibleVersion(pkg.version)) {
const msg = `Incompatible pp calculator. channel: ${module.tag}`;
wLogger.error(msg);

throw new Error(msg);
}

return loadCalculator(
await downloadCalculator(pkg.version, () => pkg.dist)
);
}

case 'release': {
wLogger.info(
`Loading external pp calculator. version: ${module.version}`
);

if (!isCompatibleVersion(module.version)) {
const msg = `Incompatible pp calculator. version: ${module.version}`;
wLogger.error(msg);

throw new Error(msg);
}

return loadCalculator(
await downloadCalculator(module.version, async () => {
const pkg = await onlinePpRegistry.fetch(module.version);
return pkg.dist;
})
);
}

case 'local': {
wLogger.info(`Loading local pp calculator. path: ${module.path}`);

return loadCalculator(module.path);
}
}
}

function loadCalculator(path: string): LazerCalculator {
return require(resolve(process.cwd(), path)) as LazerCalculator;
}

export { ppModuleManager, onlinePpRegistry };
33 changes: 33 additions & 0 deletions packages/pp-module-loader/src/package.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import * as semver from 'semver';

import { dependencies } from '../package.json';

const CALCULATOR_VERSION = new semver.SemVer(
dependencies['@tosuapp/lazer-calculator-prebuilt']
);

const PLATFORM_KEY = `${process.platform}-${process.arch}`;
const VERSION_RANGE = `^${CALCULATOR_VERSION.version}`;

export function getFullPrebuiltPackageName() {
return `@tosuapp/${getPrebuiltPackageName()}`;
}

export function isCompatibleVersion(version: string): boolean {
return semver.satisfies(version, VERSION_RANGE, {
includePrerelease: true
});
}

export function getPrebuiltPackageName() {
switch (PLATFORM_KEY) {
case 'win32-x64':
return 'lazer-calculator-win32-x64';

case 'linux-x64':
return 'lazer-calculator-linux-x64';

default:
throw new Error(`Unsupported platform: ${PLATFORM_KEY}`);
}
}
39 changes: 39 additions & 0 deletions packages/pp-module-loader/src/registry/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { PpModule } from '..';
import { getFullPrebuiltPackageName, isCompatibleVersion } from '../package';
import type { NpmPackage, NpmPackageVersion } from './types';

export const onlinePpRegistry = {
async fetchAll(): Promise<PpModule[]> {
const pkg = await npmFetch<NpmPackage>(getFullPrebuiltPackageName());

const list: PpModule[] = [];

for (const version in pkg.versions) {
if (!isCompatibleVersion(version)) continue;

list.push({ type: 'release', version });
}

for (const tag in pkg['dist-tags']) {
const version = pkg['dist-tags'][tag];
if (!isCompatibleVersion(version)) continue;

list.push({ type: 'dist-tag', tag });
}

return list;
},

fetch(specifier: string): Promise<NpmPackageVersion> {
return npmFetch<NpmPackageVersion>(
`${getFullPrebuiltPackageName()}/${specifier}`
);
}
};

async function npmFetch<T>(path: string): Promise<T> {
const res = await fetch(new URL(path, 'https://registry.npmjs.org/'));
const json = await res.json();

return json as T;
}
15 changes: 15 additions & 0 deletions packages/pp-module-loader/src/registry/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export interface NpmPackage {
'dist-tags': Record<string, string>;
versions: Record<string, NpmPackageVersion>;
}

export interface NpmPackageVersion {
name: string;
version: string;
dist: NpmPackageDist;
}

export interface NpmPackageDist {
shasum: string;
tarball: string;
}
7 changes: 7 additions & 0 deletions packages/pp-module-loader/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"./src",
"types.ts"
]
}
1 change: 1 addition & 0 deletions packages/pp-module-loader/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type * from '@tosuapp/lazer-calculator-prebuilt';
7 changes: 4 additions & 3 deletions packages/server/assets/homepage.js
Original file line number Diff line number Diff line change
Expand Up @@ -953,7 +953,8 @@ document.querySelectorAll('.text-input input').forEach((i) =>
document.querySelectorAll('.textarea-input textarea')
.forEach((i) => i.addEventListener('change', () => checkSettingsChanges()));


document.querySelectorAll('.select-input select')
.forEach((i) => i.addEventListener('change', () => checkSettingsChanges()));

const isNumberValid = (num) => {
if (typeof num === 'number') return num - num === 0;
Expand All @@ -963,7 +964,7 @@ const isNumberValid = (num) => {
};

const checkSettingsChanges = () => {
const settings = document.querySelectorAll('.settings input, .settings textarea');
const settings = document.querySelectorAll('.settings input, .settings textarea, .settings select');

const hasChanges = Array.from(settings).some((s) => {
const currentValue = s.type === 'checkbox' ? s.checked : s.value;
Expand Down Expand Up @@ -1333,7 +1334,7 @@ async function saveSettings() {
let settings = {};

document.querySelectorAll('.settings *[data-id]').forEach((s) => {
const input = s.querySelector('input, textarea');
const input = s.querySelector('input, textarea, select');
if (!input) return;

const value = input.type == 'checkbox' ? input.checked : input.value;
Expand Down
54 changes: 54 additions & 0 deletions packages/server/assets/homepage.scss
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,60 @@ a {
}
}

.select-input {
display: flex;
align-items: center;
background-color: #242d37;
border-radius: 0.3em;
width: fit-content;
height: fit-content;

select {
display: flex;
padding: 0 0.6em;
border: 0;
outline: 0;
font-size: 1rem;
width: fit-content;
min-width: 5rem;
max-width: 40rem;
height: fit-content;
min-height: 2rem;
max-height: 10rem;
field-sizing: content;
line-height: 1.7rem;
font-family: monospace;
text-align: center;
font-weight: bold;
background: transparent;
color: var(--block-color-1);

option {
background-color: var(--background-1)
}
}

&:hover,
&:focus-within {
select {
color: #6593c5;
}
}
}

.settings-item-v2:has(div > .select-input) {
& div>p {
color: #6593c5;
font-weight: bold;
opacity: 100%;
margin-right: 0.4rem;
}

& p {
opacity: 70%;
}
}

.settings-save-button {
position: sticky;
bottom: 2rem;
Expand Down
Loading
Loading