Files
3x-ui/frontend/src/utils/index.ts
T
Sanaei 92fb94d856 Move to TypeScript 7 and the oxc toolchain (oxlint + oxfmt) (#6262)
* chore(frontend,docs): move to TypeScript 7 and replace ESLint with oxlint

TypeScript 7 is the native Go port and ships no programmatic compiler
API, so typescript-eslint cannot run at all: it peer-pins
typescript >=4.8.4 <6.1.0 (canary too) and hard-crashes with
"typescript-eslint does not support TS 7.0". Upstream support is
tracked in typescript-eslint#10940 and targets TS >=7.1.

Rather than wait, or carry Microsoft's side-by-side alias (which keeps
a second TS 6 install alive purely to feed the linter), both projects
move to oxlint, which never depended on the TypeScript API.

Typecheck drops from ~9.7s to ~2.2s and 167 packages leave frontend/.

oxlint has no no-restricted-syntax, so the #6121/#6127 cleared-
InputNumber guard is reimplemented as a JS plugin in
frontend/tools/oxlint/. It was verified to still fire in
pages/settings/** and pages/xray/** and to stay exempt in *Modal.tsx.

The type-aware @deprecated sweep survives too, as
`npm run lint:deprecated`: oxlint's type-aware mode runs on
oxlint-tsgolint, which drives the TS 7 typescript-go checker, so the
TS 7 move is what makes it possible.

Behaviour is preserved rather than tightened. jsx-a11y/prefer-tag-over-role
is off in both configs because it was never part of the recommended sets
ESLint actually ran, and oxlint honours the existing eslint-disable
comments, so no source churn was needed.

Two real fixes fell out of the stricter linting:
- outbound-link-parser.test.ts used `out?.streamSettings` behind an `as`
  cast, which hid the optional chain from ESLint and would throw on a
  null parse; the rest of the file already used `out!`.
- InputAddon's conditional role/tabIndex/onKeyDown is genuinely
  accessible but oxlint cannot evaluate it, so it gets a scoped disable.

* chore(docs): replace Prettier with oxfmt

oxfmt is the oxc project's Prettier-compatible formatter, so this pairs
with the oxlint move and drops the last JS-based tool from the docs
toolchain.

The swap is behaviour-preserving. Running Prettier and oxfmt over the
same files, with the existing .prettierrc.json settings migrated via
`oxfmt --migrate=prettier`, produces byte-identical output on every
file. (Comparing them outside the project directory is misleading:
Prettier silently falls back to its defaults when it cannot find its
config, which looks like a mismatch but is not one.)

The 18 files reformatted here were already failing `pnpm format:check`
before this change — Prettier wanted the exact same edits. The check is
not part of docs-ci.yml, which is why the drift went unnoticed.

.prettierignore becomes ignorePatterns in .oxfmtrc.json, keeping the
deliberate MDX exclusion: reflowing MDX prose merges headings into
paragraphs and collapses lists inside Steps/Callout components. Both
that and the generated fumadocs-openapi reference output were verified
untouched.

oxfmt is pinned to 0.63.0 rather than latest. pnpm 11's built-in
minimumReleaseAge policy rejects same-day releases, and 0.64.0 would
have made pnpm silently append 20 waiver lines to pnpm-workspace.yaml.

* style(frontend): adopt oxfmt and format src

frontend/ has never had a formatter, so this reformats 344 of 497 files
in src/. The change is purely whitespace, quoting and line wrapping —
no logic is touched. It is kept in its own commit so it does not bury
the TypeScript 7 / oxlint migration or the git blame for the code
itself.

Settings match docs/ and the code as it was already written: single
quotes, semicolons, trailing commas, 2-space indent, 100 columns. That
was measured rather than assumed — src/ was already uniformly
single-quoted and 2-space indented, with p90 line length at 75.

Formatting is scoped to src/ (mirroring `oxlint src`) and
.oxfmtrc.json ignores src/generated. Both matter: `make gen-check`
compares src/generated and public/openapi.json, and
`make msw-worker-check` byte-compares public/mockServiceWorker.js
against the installed MSW runtime, so reformatting any of them breaks
the gate.

Reflowing also moves `eslint-disable-next-line` comments off the line
they guard, which broke two suppressions that had been silently
correct before:
- clone-inbound-modal.test.tsx: the object literal became multi-line,
  leaving `} as any;` four lines below its no-explicit-any disable.
- ClientsPage.tsx: the useMemo dependency array moved onto its own
  line, out from under its exhaustive-deps disable.
Both comments were relocated onto the line they actually guard, and
verified to still suppress by removing them and watching the errors
return.

* ci: enforce formatting in CI and make verify

Adding oxfmt in the previous two commits gave both projects a formatter
but nothing that checks it, which is how docs/ had already drifted to 18
unformatted files: docs-ci.yml runs typecheck, lint, test and build, but
never format:check, so Prettier's complaints were only ever visible to
whoever ran it by hand.

Wire `format:check` into the frontend job in ci.yml and the docs job in
docs-ci.yml, and add a `format-check` target to `make verify` so the
local gate keeps mirroring CI as the Makefile header promises.

Verified the step actually bites rather than passing vacuously: adding
a badly formatted line to a source file in each project makes both
`make format-check` and `pnpm format:check` fail, and reverting it makes
them pass again.

No workflow referenced ESLint or Prettier by name — they all invoke the
package scripts — so the tooling swap needed no other CI changes.

* ci: trigger CI on Makefile changes

The path filters listed **.go, go.mod, go.sum, frontend/**, .nvmrc and
ci.yml itself, but not the Makefile — so a change to the canonical task
runner that ci.yml is meant to mirror could land without any job
running. The previous commit, which edits both, only triggers because
it happens to touch ci.yml too.

* fix(frontend): replace deprecated Ant Design 6 APIs in the geo components

`npm run lint:deprecated` reported five uses of props Ant Design 6 has
deprecated. All five are gone, and the matching runtime warnings no
longer appear in the test output.

Tag `bordered={false}` becomes `variant="filled"` and Space `direction`
becomes `orientation`; both are the one-to-one replacements named in
antd's own deprecation messages, and `direction`/`orientation` share the
same Orientation type.

Input `addonAfter` is the one that is not a rename. It becomes a
`Space.Compact block` wrapping the Input and the browse Button, which is
antd's documented migration. `block` keeps the field filling its form
row as the addon did. Note this is a deliberate visual change: the
button used to be a borderless `type="text"` icon sitting inside the
addon's grey box, and is now a regular button whose border joins the
input. The tooltip, aria-label, ref, id and onBlur wiring are unchanged,
so the react-hook-form binding in RuleFormModal and the existing tests
still address it the same way.

Only these five were deprecated. The other `bordered` props in the tree
sit on QRCode, Table, Descriptions and Alert, where the prop is not
deprecated, and these were the only two Space `direction` uses in the
codebase.

* fix(frontend): restore lint rules lost in the oxlint migration, and test the guard

Addresses the review on #6262.

The frontend config re-enabled only no-explicit-any and no-unused-vars
and left the rest of tseslint's recommended set to oxlint's correctness
category. It does not cover all of it. Confirmed by linting one probe
file against both configs: docs/ (which enumerates the rules) reports
all nine, frontend/ reported four. So ban-ts-comment,
no-empty-object-type, no-namespace, no-require-imports and
no-unsafe-function-type had silently stopped being enforced — a `//
@ts-ignore` or a `namespace` block would have landed unflagged. The ten
rules are now mirrored from docs/.oxlintrc.json, and src/ still passes.

The #6121/#6127 guard was 57 lines of hand-written AST walking with no
test. It now has one: fixtures for the three banned shapes plus an
onNumber()-wrapped control, asserting the rule fires three times and
that .oxlintrc.json still wires it to the right paths. Verified it fails
for the right reason by making walk() enumerate nothing, which is the
silent-death mode the review described — the traversal depends on
Object.keys() seeing AST children as own enumerable properties.

The fixtures deliberately violate the rule, so their oxlint config is
named guard.oxlintrc.json rather than .oxlintrc.json: oxlint discovers
nested configs by directory, which would otherwise turn the fixtures
into three lint errors. The test passes it explicitly with -c.

Also from the review:
- lint and format now cover tools/ as well as src/, so the one piece of
  hand-written lint logic in the repo is no longer the least covered
  file in it.
- lint-staged runs oxfmt before oxlint --fix. Formatting became a hard
  CI gate in this PR while the hook only ran the linter, so a commit
  could pass the hook and fail CI on formatting alone.
- .oxfmtrc.json ignores public/, so the artefacts that make gen-check
  and make msw-worker-check byte-compare stay safe even if oxfmt is
  invoked without a path argument.
- The MDX and generated-reference rationales that .prettierignore
  carried are back as comments in docs/.oxfmtrc.json — oxlint and oxfmt
  both accept JSONC, so relocating them was unnecessary.

Not applied: the review also suggested restoring ../internal/web/dist to
the ignore lists. Both tools reject `..` patterns outright ("patterns
are resolved within the config file's directory"), and being outside
frontend/ it is unreachable anyway.
2026-08-19 15:36:27 +02:00

1014 lines
29 KiB
TypeScript

import i18next from 'i18next';
import { httpRequest } from '@/api/http-init';
import type { HttpResponse } from '@/api/http-init';
import { getMessage } from './messageBus';
type RespEnvelope = { success?: unknown; msg?: unknown; obj?: unknown };
export class Msg<T = unknown> {
success: boolean;
msg: string;
obj: T | null;
constructor(success: boolean = false, msg: string = '', obj: T | null = null) {
this.success = success;
this.msg = msg;
this.obj = obj;
}
}
export interface HttpOptions {
headers?: Record<string, string> | Headers;
params?: unknown;
timeout?: number;
signal?: AbortSignal;
silent?: boolean;
silentSuccess?: boolean;
}
export interface HttpModal {
loading: (state: boolean) => void;
close: () => void;
}
export class HttpUtil {
static _handleMsg(msg: unknown, silentSuccess = false): void {
if (!(msg instanceof Msg) || msg.msg === '') {
return;
}
if (msg.success) {
if (!silentSuccess) {
getMessage().success(msg.msg);
}
if (
msg.obj &&
typeof msg.obj === 'object' &&
(msg.obj as { nodePending?: unknown }).nodePending === true
) {
getMessage().warning(i18next.t('pages.inbounds.toasts.savedNodeOfflineWillSync'));
}
return;
}
getMessage().error(msg.msg);
}
static _respToMsg(resp: HttpResponse | undefined): Msg {
if (!resp || !resp.data) {
return new Msg(false, 'No response data');
}
const { data } = resp;
if (data == null) {
return new Msg(true);
}
if (typeof data === 'object' && 'success' in (data as object)) {
const d = data as RespEnvelope;
return new Msg(Boolean(d.success), typeof d.msg === 'string' ? d.msg : '', d.obj ?? null);
}
return typeof data === 'object' ? (data as Msg) : new Msg(false, 'unknown data:', data);
}
static async get<T = unknown>(
url: string,
params?: unknown,
options: HttpOptions = {},
): Promise<Msg<T>> {
const { silent, silentSuccess, ...rest } = options;
try {
const resp = await httpRequest('GET', url, undefined, { ...rest, params });
const msg = this._respToMsg(resp) as Msg<T>;
if (!silent) this._handleMsg(msg, silentSuccess);
return msg;
} catch (error) {
const err = error as {
response?: { data?: { msg?: string; message?: string } };
message?: string;
};
const data = err.response?.data;
const errorMsg = new Msg<T>(
false,
data?.msg || data?.message || err.message || 'Request failed',
);
if (!silent) {
console.error('GET request failed:', error);
this._handleMsg(errorMsg);
}
return errorMsg;
}
}
static async post<T = unknown>(
url: string,
data?: unknown,
options: HttpOptions = {},
): Promise<Msg<T>> {
const { silent, silentSuccess, ...rest } = options;
try {
const resp = await httpRequest('POST', url, data, rest);
const msg = this._respToMsg(resp) as Msg<T>;
if (!silent) this._handleMsg(msg, silentSuccess);
return msg;
} catch (error) {
const err = error as {
response?: { data?: { msg?: string; message?: string } };
message?: string;
};
const data = err.response?.data;
const errorMsg = new Msg<T>(
false,
data?.msg || data?.message || err.message || 'Request failed',
);
if (!silent) {
console.error('POST request failed:', error);
this._handleMsg(errorMsg);
}
return errorMsg;
}
}
static async delete<T = unknown>(url: string, options: HttpOptions = {}): Promise<Msg<T>> {
const { silent, silentSuccess, ...rest } = options;
try {
const resp = await httpRequest('DELETE', url, undefined, rest);
const msg = this._respToMsg(resp) as Msg<T>;
if (!silent) this._handleMsg(msg, silentSuccess);
return msg;
} catch (error) {
console.error('DELETE request failed:', error);
const err = error as {
response?: { data?: { msg?: string; message?: string } };
message?: string;
};
const data = err.response?.data;
const errorMsg = new Msg<T>(
false,
data?.msg || data?.message || err.message || 'Request failed',
);
if (!silent) this._handleMsg(errorMsg);
return errorMsg;
}
}
static async postWithModal<T = unknown>(
url: string,
data?: unknown,
modal?: HttpModal | null,
): Promise<Msg<T>> {
if (modal) {
modal.loading(true);
}
const msg = await this.post<T>(url, data);
if (modal) {
modal.loading(false);
if (msg instanceof Msg && msg.success) {
modal.close();
}
}
return msg;
}
}
export function applyDocumentTitle(): void {
const host = window.location.hostname;
if (!host) return;
const current = document.title.trim();
document.title = current ? `${host} - ${current}` : host;
}
export class PromiseUtil {
static async sleep(timeout: number): Promise<void> {
await new Promise<void>((resolve) => {
setTimeout(resolve, timeout);
});
}
}
export interface RandomSeqOptions {
type?: 'default' | 'hex';
hasNumbers?: boolean;
hasLowercase?: boolean;
hasUppercase?: boolean;
}
export class RandomUtil {
static getSeq({
type = 'default',
hasNumbers = true,
hasLowercase = true,
hasUppercase = true,
}: RandomSeqOptions = {}): string {
let seq = '';
switch (type) {
case 'hex':
seq += '0123456789abcdef';
break;
default:
if (hasNumbers) seq += '0123456789';
if (hasLowercase) seq += 'abcdefghijklmnopqrstuvwxyz';
if (hasUppercase) seq += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
break;
}
return seq;
}
static randomInteger(min: number, max: number): number {
const range = max - min + 1;
const randomBuffer = new Uint32Array(1);
window.crypto.getRandomValues(randomBuffer);
return Math.floor((randomBuffer[0] / (0xffffffff + 1)) * range) + min;
}
static randomSeq(count: number, options: RandomSeqOptions = {}): string {
const seq = this.getSeq(options);
const seqLength = seq.length;
const randomValues = new Uint32Array(count);
window.crypto.getRandomValues(randomValues);
return Array.from(randomValues, (v) => seq[v % seqLength]).join('');
}
static randomShortIds(): string {
const lengths = [2, 4, 6, 8, 10, 12, 14, 16].sort(() => Math.random() - 0.5);
return lengths.map((len) => this.randomSeq(len, { type: 'hex' })).join(',');
}
static randomLowerAndNum(len: number): string {
return this.randomSeq(len, { hasUppercase: false });
}
static randomUUID(): string {
if (window.location.protocol === 'https:') {
return window.crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const randomValues = new Uint8Array(1);
window.crypto.getRandomValues(randomValues);
const randomValue = randomValues[0] % 16;
const calculatedValue = c === 'x' ? randomValue : (randomValue & 0x3) | 0x8;
return calculatedValue.toString(16);
});
}
static randomShadowsocksPassword(method: string = '2022-blake3-aes-256-gcm'): string {
const length = method === '2022-blake3-aes-128-gcm' ? 16 : 32;
const array = new Uint8Array(length);
window.crypto.getRandomValues(array);
return Base64.alternativeEncode(String.fromCharCode(...array));
}
static isShadowsocks2022Password(password: string, method: string): boolean {
if (!method || method.substring(0, 4) !== '2022') return true;
const expected = method === '2022-blake3-aes-128-gcm' ? 16 : 32;
try {
return window.atob(password).length === expected;
} catch {
return false;
}
}
static randomBase64(length: number = 16): string {
const array = new Uint8Array(length);
window.crypto.getRandomValues(array);
return Base64.alternativeEncode(String.fromCharCode(...array));
}
static randomBase32String(length: number = 16): string {
const array = new Uint8Array(length);
window.crypto.getRandomValues(array);
const base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let result = '';
let bits = 0;
let buffer = 0;
for (let i = 0; i < array.length; i++) {
buffer = (buffer << 8) | array[i];
bits += 8;
while (bits >= 5) {
bits -= 5;
result += base32Chars[(buffer >>> bits) & 0x1f];
}
}
if (bits > 0) {
result += base32Chars[(buffer << (5 - bits)) & 0x1f];
}
return result;
}
}
type AnyRecord = Record<string, unknown>;
export class ObjectUtil {
static getPropIgnoreCase(obj: AnyRecord, prop: string): unknown {
for (const name in obj) {
if (!Object.prototype.hasOwnProperty.call(obj, name)) continue;
if (name.toLowerCase() === prop.toLowerCase()) {
return obj[name];
}
}
return undefined;
}
static deepSearch(obj: unknown, key: string): boolean {
if (obj instanceof Array) {
for (let i = 0; i < obj.length; ++i) {
if (this.deepSearch(obj[i], key)) return true;
}
} else if (obj instanceof Object) {
const rec = obj as AnyRecord;
for (const name in rec) {
if (!Object.prototype.hasOwnProperty.call(rec, name)) continue;
if (this.deepSearch(rec[name], key)) return true;
}
} else {
return this.isEmpty(obj) ? false : String(obj).toLowerCase().indexOf(key.toLowerCase()) >= 0;
}
return false;
}
static isEmpty(obj: unknown): boolean {
return obj === null || obj === undefined || obj === '';
}
static isArrEmpty(arr: unknown): boolean {
return !Array.isArray(arr) || arr.length === 0;
}
static copyArr<T>(dest: T[], src: T[]): void {
dest.splice(0);
for (const item of src) {
dest.push(item);
}
}
static clone<T>(obj: T): T {
if (obj instanceof Array) {
const newArr: unknown[] = [];
this.copyArr(newArr, obj);
return newArr as unknown as T;
}
if (obj instanceof Object) {
const newObj: AnyRecord = {};
const rec = obj as unknown as AnyRecord;
for (const key of Object.keys(rec)) {
newObj[key] = rec[key];
}
return newObj as unknown as T;
}
return obj;
}
static deepClone<T>(obj: T): T {
if (obj instanceof Array) {
const newArr: unknown[] = [];
for (const item of obj) {
newArr.push(this.deepClone(item));
}
return newArr as unknown as T;
}
if (obj instanceof Object) {
const newObj: AnyRecord = {};
const rec = obj as unknown as AnyRecord;
for (const key of Object.keys(rec)) {
newObj[key] = this.deepClone(rec[key]);
}
return newObj as unknown as T;
}
return obj;
}
static cloneProps(dest: object, src: object, ...ignoreProps: string[]): void {
if (dest == null || src == null) return;
const ignoreEmpty = this.isArrEmpty(ignoreProps);
const d = dest as AnyRecord;
const s = src as AnyRecord;
for (const key of Object.keys(s)) {
if (!Object.prototype.hasOwnProperty.call(s, key)) continue;
if (!Object.prototype.hasOwnProperty.call(d, key)) continue;
if (s[key] === undefined) continue;
if (ignoreEmpty) {
d[key] = s[key];
} else {
let ignore = false;
for (let i = 0; i < ignoreProps.length; ++i) {
if (key === ignoreProps[i]) {
ignore = true;
break;
}
}
if (!ignore) {
d[key] = s[key];
}
}
}
}
static delProps(obj: object, ...props: string[]): void {
const o = obj as AnyRecord;
for (const prop of props) {
if (prop in o) {
delete o[prop];
}
}
}
static execute(func: unknown, ...args: unknown[]): void {
if (!this.isEmpty(func) && typeof func === 'function') {
(func as (...a: unknown[]) => unknown)(...args);
}
}
static orDefault<T>(obj: T | null | undefined, defaultValue: T): T {
if (obj == null) return defaultValue;
return obj;
}
static equals(a: unknown, b: unknown): boolean {
if (a == null || b == null || typeof a !== 'object' || typeof b !== 'object') {
return a === b;
}
const ra = a as AnyRecord;
const rb = b as AnyRecord;
const aKeys = Object.keys(ra);
const bKeys = Object.keys(rb);
if (aKeys.length !== bKeys.length) return false;
for (const key of aKeys) {
if (!Object.prototype.hasOwnProperty.call(rb, key)) return false;
if (ra[key] !== rb[key]) return false;
}
return true;
}
}
export class Wireguard {
static gf(init?: ArrayLike<number>): Float64Array {
const r = new Float64Array(16);
if (init) {
for (let i = 0; i < init.length; ++i) r[i] = init[i];
}
return r;
}
static pack(o: Uint8Array, n: Float64Array): void {
let b: number;
const m = this.gf();
const t = this.gf();
for (let i = 0; i < 16; ++i) t[i] = n[i];
this.carry(t);
this.carry(t);
this.carry(t);
for (let j = 0; j < 2; ++j) {
m[0] = t[0] - 0xffed;
for (let i = 1; i < 15; ++i) {
m[i] = t[i] - 0xffff - ((m[i - 1] >> 16) & 1);
m[i - 1] &= 0xffff;
}
m[15] = t[15] - 0x7fff - ((m[14] >> 16) & 1);
b = (m[15] >> 16) & 1;
m[14] &= 0xffff;
this.cswap(t, m, 1 - b);
}
for (let i = 0; i < 16; ++i) {
o[2 * i] = t[i] & 0xff;
o[2 * i + 1] = t[i] >> 8;
}
}
static carry(o: Float64Array): void {
for (let i = 0; i < 16; ++i) {
o[(i + 1) % 16] += (i < 15 ? 1 : 38) * Math.floor(o[i] / 65536);
o[i] &= 0xffff;
}
}
static cswap(p: Float64Array, q: Float64Array, b: number): void {
const c = ~(b - 1);
let t: number;
for (let i = 0; i < 16; ++i) {
t = c & (p[i] ^ q[i]);
p[i] ^= t;
q[i] ^= t;
}
}
static add(o: Float64Array, a: Float64Array, b: Float64Array): void {
for (let i = 0; i < 16; ++i) o[i] = (a[i] + b[i]) | 0;
}
static subtract(o: Float64Array, a: Float64Array, b: Float64Array): void {
for (let i = 0; i < 16; ++i) o[i] = (a[i] - b[i]) | 0;
}
static multmod(o: Float64Array, a: Float64Array, b: Float64Array): void {
const t = new Float64Array(31);
for (let i = 0; i < 16; ++i) {
for (let j = 0; j < 16; ++j) t[i + j] += a[i] * b[j];
}
for (let i = 0; i < 15; ++i) t[i] += 38 * t[i + 16];
for (let i = 0; i < 16; ++i) o[i] = t[i];
this.carry(o);
this.carry(o);
}
static invert(o: Float64Array, i: Float64Array): void {
const c = this.gf();
for (let a = 0; a < 16; ++a) c[a] = i[a];
for (let a = 253; a >= 0; --a) {
this.multmod(c, c, c);
if (a !== 2 && a !== 4) this.multmod(c, c, i);
}
for (let a = 0; a < 16; ++a) o[a] = c[a];
}
static clamp(z: Uint8Array): void {
z[31] = (z[31] & 127) | 64;
z[0] &= 248;
}
static generatePublicKey(privateKey: Uint8Array): Uint8Array {
let r: number;
const z = new Uint8Array(32);
const a = this.gf([1]);
const b = this.gf([9]);
const c = this.gf();
const d = this.gf([1]);
const e = this.gf();
const f = this.gf();
const _121665 = this.gf([0xdb41, 1]);
const _9 = this.gf([9]);
for (let i = 0; i < 32; ++i) z[i] = privateKey[i];
this.clamp(z);
for (let i = 254; i >= 0; --i) {
r = (z[i >>> 3] >>> (i & 7)) & 1;
this.cswap(a, b, r);
this.cswap(c, d, r);
this.add(e, a, c);
this.subtract(a, a, c);
this.add(c, b, d);
this.subtract(b, b, d);
this.multmod(d, e, e);
this.multmod(f, a, a);
this.multmod(a, c, a);
this.multmod(c, b, e);
this.add(e, a, c);
this.subtract(a, a, c);
this.multmod(b, a, a);
this.subtract(c, d, f);
this.multmod(a, c, _121665);
this.add(a, a, d);
this.multmod(c, c, a);
this.multmod(a, d, f);
this.multmod(d, b, _9);
this.multmod(b, e, e);
this.cswap(a, b, r);
this.cswap(c, d, r);
}
this.invert(c, c);
this.multmod(a, a, c);
this.pack(z, a);
return z;
}
static generatePresharedKey(): Uint8Array {
const privateKey = new Uint8Array(32);
window.crypto.getRandomValues(privateKey);
return privateKey;
}
static generatePrivateKey(): Uint8Array {
const privateKey = this.generatePresharedKey();
this.clamp(privateKey);
return privateKey;
}
static encodeBase64(dest: Uint8Array, src: Uint8Array): void {
const input = Uint8Array.from([
(src[0] >> 2) & 63,
((src[0] << 4) | (src[1] >> 4)) & 63,
((src[1] << 2) | (src[2] >> 6)) & 63,
src[2] & 63,
]);
for (let i = 0; i < 4; ++i) {
dest[i] =
input[i] +
65 +
(((25 - input[i]) >> 8) & 6) -
(((51 - input[i]) >> 8) & 75) -
(((61 - input[i]) >> 8) & 15) +
(((62 - input[i]) >> 8) & 3);
}
}
static keyToBase64(key: Uint8Array): string {
let i: number;
const base64 = new Uint8Array(44);
for (i = 0; i < 32 / 3; ++i) {
this.encodeBase64(base64.subarray(i * 4), key.subarray(i * 3));
}
this.encodeBase64(base64.subarray(i * 4), Uint8Array.from([key[i * 3 + 0], key[i * 3 + 1], 0]));
base64[43] = 61;
return String.fromCharCode.apply(null, Array.from(base64));
}
static keyFromBase64(encoded: string): Uint8Array {
const binaryStr = atob(encoded);
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i);
}
return bytes;
}
static generateKeypair(secretKey: string = ''): { publicKey: string; privateKey: string } {
const privateKey =
secretKey.length > 0 ? this.keyFromBase64(secretKey) : this.generatePrivateKey();
const publicKey = this.generatePublicKey(privateKey);
return {
publicKey: this.keyToBase64(publicKey),
privateKey: secretKey.length > 0 ? secretKey : this.keyToBase64(privateKey),
};
}
}
export class ClipboardManager {
static async copyText(content: unknown = ''): Promise<boolean> {
const text = String(content ?? '');
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {}
}
return ClipboardManager._legacyCopy(text);
}
static _legacyCopy(text: string): boolean {
const span = document.createElement('span');
span.textContent = text;
span.style.whiteSpace = 'pre';
span.style.position = 'absolute';
span.style.left = '-9999px';
span.style.top = '0';
document.body.appendChild(span);
const selection = window.getSelection();
if (!selection) {
document.body.removeChild(span);
return false;
}
const prevSelection = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
selection.removeAllRanges();
const range = window.document.createRange();
range.selectNodeContents(span);
selection.addRange(range);
let ok = false;
try {
const exec = (document as unknown as Record<string, unknown>)['execCommand'];
if (typeof exec === 'function') {
ok = (exec as (cmd: string) => boolean).call(document, 'copy');
}
} catch {}
selection.removeAllRanges();
if (prevSelection) {
selection.addRange(prevSelection);
}
document.body.removeChild(span);
return ok;
}
}
export class Base64 {
static encode(content: string = '', safe: boolean = false): string {
if (safe) {
return Base64.encode(content).replace(/\+/g, '-').replace(/=/g, '').replace(/\//g, '_');
}
return window.btoa(String.fromCharCode(...new TextEncoder().encode(content)));
}
static alternativeEncode(content: string): string {
return window.btoa(content);
}
static decode(content: string = ''): string {
const normalized = content.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
return new TextDecoder().decode(Uint8Array.from(window.atob(padded), (c) => c.charCodeAt(0)));
}
}
export class SizeFormatter {
static readonly ONE_KB = 1024;
static readonly ONE_MB = SizeFormatter.ONE_KB * 1024;
static readonly ONE_GB = SizeFormatter.ONE_MB * 1024;
static readonly ONE_TB = SizeFormatter.ONE_GB * 1024;
static readonly ONE_PB = SizeFormatter.ONE_TB * 1024;
static sizeFormat(size: number | null | undefined): string {
if (size == null || !Number.isFinite(size) || size <= 0) return '0 B';
if (size < SizeFormatter.ONE_KB) return size.toFixed(0) + ' B';
if (size < SizeFormatter.ONE_MB) return (size / SizeFormatter.ONE_KB).toFixed(2) + ' KB';
if (size < SizeFormatter.ONE_GB) return (size / SizeFormatter.ONE_MB).toFixed(2) + ' MB';
if (size < SizeFormatter.ONE_TB) return (size / SizeFormatter.ONE_GB).toFixed(2) + ' GB';
if (size < SizeFormatter.ONE_PB) return (size / SizeFormatter.ONE_TB).toFixed(2) + ' TB';
return (size / SizeFormatter.ONE_PB).toFixed(2) + ' PB';
}
// Same unit ladder as sizeFormat, expressed per-second.
static speedFormat(bps: number | null | undefined): string {
return SizeFormatter.sizeFormat(bps) + '/s';
}
}
export class CPUFormatter {
static cpuSpeedFormat(speed: number): string {
return speed > 1000 ? (speed / 1000).toFixed(2) + ' GHz' : speed.toFixed(2) + ' MHz';
}
static cpuCoreFormat(cores: number): string {
return cores === 1 ? '1 Core' : cores + ' Cores';
}
}
export class TimeFormatter {
static formatClock(unixSec: number): string {
const d = new Date(unixSec * 1000);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
static formatSecond(second: number): string {
if (second < 60) return second.toFixed(0) + 's';
if (second < 3600) return (second / 60).toFixed(0) + 'm';
if (second < 3600 * 24) return (second / 3600).toFixed(0) + 'h';
const day = Math.floor(second / 3600 / 24);
const remain = Number((second / 3600 - day * 24).toFixed(0));
return day + 'd' + (remain > 0 ? ' ' + remain + 'h' : '');
}
}
export class NumberFormatter {
static addZero(num: number): string | number {
return num < 10 ? '0' + num : num;
}
static toFixed(num: number, n: number): number {
const m = Math.pow(10, n);
return Math.floor(num * m) / m;
}
}
export class CookieManager {
static getCookie(cname: string): string {
const name = cname + '=';
const ca = document.cookie.split(';');
for (let c of ca) {
c = c.trim();
if (c.indexOf(name) === 0) {
return decodeURIComponent(c.substring(name.length, c.length));
}
}
return '';
}
static setCookie(cname: string, cvalue: string, exdays?: number): void {
let expires = '';
if (exdays) {
const d = new Date();
d.setTime(d.getTime() + exdays * 24 * 60 * 60 * 1000);
expires = 'expires=' + d.toUTCString() + ';';
}
document.cookie = cname + '=' + encodeURIComponent(cvalue) + ';' + expires + 'path=/';
}
}
const COLORS = {
success: '#389e0a',
warning: '#faad14',
danger: '#ff4d4f',
purple: '#722ed1',
} as const;
export type UsageColor = 'purple' | 'green' | 'orange' | 'red';
export interface ClientUsageStats {
total: number;
up: number;
down: number;
}
export interface ExpiryClient {
enable: boolean;
expiryTime: number | null;
}
export class ColorUtils {
static usageColor(
data: number | null | undefined,
threshold: number,
total: number | { valueOf(): number } | null | undefined,
): UsageColor {
const t = Number(total ?? 0);
const d = Number(data);
switch (true) {
case data === null || data === undefined:
return 'purple';
case t < 0:
return 'green';
case t == 0:
return 'purple';
case d < t - threshold:
return 'green';
case d < t:
return 'orange';
default:
return 'red';
}
}
static clientUsageColor(
clientStats: ClientUsageStats | null | undefined,
trafficDiff: number,
): string {
switch (true) {
case !clientStats || clientStats.total == 0:
return COLORS.purple;
case clientStats!.up + clientStats!.down < clientStats!.total - trafficDiff:
return COLORS.success;
case clientStats!.up + clientStats!.down < clientStats!.total:
return COLORS.warning;
default:
return COLORS.danger;
}
}
static userExpiryColor(threshold: number, client: ExpiryClient, isDark: boolean = false): string {
if (!client.enable) return isDark ? '#2c3950' : '#bcbcbc';
const now = new Date().getTime();
const expiry = client.expiryTime;
switch (true) {
case expiry === null:
return COLORS.purple;
case (expiry as number) < 0:
return COLORS.success;
case (expiry as number) == 0:
return COLORS.purple;
case now < (expiry as number) - threshold:
return COLORS.success;
case now < (expiry as number):
return COLORS.warning;
default:
return COLORS.danger;
}
}
}
export interface SupportedLanguage {
name: string;
value: string;
icon: string;
}
export class LanguageManager {
static readonly supportedLanguages: readonly SupportedLanguage[] = [
{ name: 'العربية', value: 'ar-EG', icon: '🇪🇬' },
{ name: 'English', value: 'en-US', icon: '🇺🇸' },
{ name: 'فارسی', value: 'fa-IR', icon: '🇮🇷' },
{ name: '简体中文', value: 'zh-CN', icon: '🇨🇳' },
{ name: '繁體中文', value: 'zh-TW', icon: '🇹🇼' },
{ name: '日本語', value: 'ja-JP', icon: '🇯🇵' },
{ name: 'Русский', value: 'ru-RU', icon: '🇷🇺' },
{ name: 'Tiếng Việt', value: 'vi-VN', icon: '🇻🇳' },
{ name: 'Español', value: 'es-ES', icon: '🇪🇸' },
{ name: 'Indonesian', value: 'id-ID', icon: '🇮🇩' },
{ name: 'Український', value: 'uk-UA', icon: '🇺🇦' },
{ name: 'Türkçe', value: 'tr-TR', icon: '🇹🇷' },
{ name: 'Português', value: 'pt-BR', icon: '🇧🇷' },
];
static getLanguage(): string {
let lang = CookieManager.getCookie('lang');
if (lang) return lang;
if (window.navigator) {
const nav = window.navigator as Navigator & { userLanguage?: string };
lang = nav.language || nav.userLanguage || '';
const simularLangs: [string, string][] = [
['ar', LanguageManager.supportedLanguages[0].value],
['fa', LanguageManager.supportedLanguages[2].value],
['ja', LanguageManager.supportedLanguages[5].value],
['ru', LanguageManager.supportedLanguages[6].value],
['vi', LanguageManager.supportedLanguages[7].value],
['es', LanguageManager.supportedLanguages[8].value],
['id', LanguageManager.supportedLanguages[9].value],
['uk', LanguageManager.supportedLanguages[10].value],
['tr', LanguageManager.supportedLanguages[11].value],
['pt', LanguageManager.supportedLanguages[12].value],
];
simularLangs.forEach((pair) => {
if (lang === pair[0]) {
lang = pair[1];
}
});
if (LanguageManager.isSupportLanguage(lang)) {
CookieManager.setCookie('lang', lang, 365);
} else {
CookieManager.setCookie('lang', 'en-US', 365);
window.location.reload();
}
} else {
CookieManager.setCookie('lang', 'en-US', 365);
window.location.reload();
}
return lang;
}
static setLanguage(language: string): void {
if (!LanguageManager.isSupportLanguage(language)) {
language = 'en-US';
}
CookieManager.setCookie('lang', language, 365);
window.location.reload();
}
static isSupportLanguage(language: string): boolean {
return LanguageManager.supportedLanguages.some((lang) => lang.value === language);
}
}
export class FileManager {
static downloadTextFile(
content: BlobPart,
filename: string = 'file.txt',
options: BlobPropertyBag = { type: 'text/plain' },
): void {
const link = window.document.createElement('a');
link.download = filename;
link.style.border = '0';
link.style.padding = '0';
link.style.margin = '0';
link.style.position = 'absolute';
link.style.left = '-9999px';
link.style.top = `${window.pageYOffset || window.document.documentElement.scrollTop}px`;
link.href = URL.createObjectURL(new Blob([content], options));
link.click();
URL.revokeObjectURL(link.href);
link.remove();
}
}
export type CalendarKind = 'gregorian' | 'jalalian';
export class IntlUtil {
static formatDate(
date: string | number | Date | null | undefined,
calendar: CalendarKind = 'gregorian',
): string {
if (date == null) return '';
const d = new Date(date);
if (!isFinite(d.getTime())) return '';
const language = LanguageManager.getLanguage();
const locale = calendar === 'jalalian' ? 'fa-IR' : language;
const intlOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
};
const intl = new Intl.DateTimeFormat(locale, intlOptions);
return intl.format(d);
}
static formatRelativeTime(date: number | null | undefined): string {
if (date == null) return '';
if (!isFinite(date)) return '';
const language = LanguageManager.getLanguage();
const now = new Date();
const diff =
date < 0
? Math.round(date / (1000 * 60 * 60 * 24))
: Math.round((date - now.getTime()) / (1000 * 60 * 60 * 24));
const formatter = new Intl.RelativeTimeFormat(language, { numeric: 'auto' });
return formatter.format(diff, 'day');
}
}