initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {execSync} from 'node:child_process';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import * as esbuild from 'esbuild';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDir, '..');
const allowedChannels = new Set(['stable', 'canary']);
const rawChannel = process.env.BUILD_CHANNEL?.toLowerCase() ?? '';
const channel = allowedChannels.has(rawChannel) ? rawChannel : 'stable';
console.log(`Building Electron with channel: ${channel}`);
execSync('node scripts/set-build-channel.mjs', {cwd: projectRoot, stdio: 'inherit'});
execSync('npx tsc -p tsconfig.electron.json', {cwd: projectRoot, stdio: 'inherit'});
await esbuild.build({
entryPoints: [path.join(projectRoot, 'src-electron/preload/index.ts')],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: path.join(projectRoot, 'src-electron/dist/preload/index.js'),
external: ['electron'],
define: {
'process.env.BUILD_CHANNEL': JSON.stringify(channel),
},
sourcemap: true,
});
console.log('Electron build complete');

View File

@@ -0,0 +1,27 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {buildServiceWorker} from './build/utils/service-worker.js';
const isProduction = process.env.NODE_ENV === 'production';
buildServiceWorker(isProduction).catch((error) => {
console.error('Service worker build failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,88 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as path from 'node:path';
export const ROOT_DIR = path.resolve(import.meta.dirname, '..', '..');
export const SRC_DIR = path.join(ROOT_DIR, 'src');
export const DIST_DIR = path.join(ROOT_DIR, 'dist');
export const ASSETS_DIR = path.join(DIST_DIR, 'assets');
export const PKGS_DIR = path.join(ROOT_DIR, 'pkgs');
export const PUBLIC_DIR = path.join(ROOT_DIR, 'assets');
export const CDN_ENDPOINT = 'https://fluxerstatic.com';
export const DEV_PORT = 3000;
export const RESOLVE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.json', '.mjs', '.cjs'];
export const LOCALES = [
'ar',
'bg',
'cs',
'da',
'de',
'el',
'en-GB',
'en-US',
'es-419',
'es-ES',
'fi',
'fr',
'he',
'hi',
'hr',
'hu',
'id',
'it',
'ja',
'ko',
'lt',
'nl',
'no',
'pl',
'pt-BR',
'ro',
'ru',
'sv-SE',
'th',
'tr',
'uk',
'vi',
'zh-CN',
'zh-TW',
];
export const FILE_LOADERS: Record<string, 'file'> = {
'.woff': 'file',
'.woff2': 'file',
'.ttf': 'file',
'.eot': 'file',
'.png': 'file',
'.jpg': 'file',
'.jpeg': 'file',
'.gif': 'file',
'.webp': 'file',
'.ico': 'file',
'.mp3': 'file',
'.wav': 'file',
'.ogg': 'file',
'.mp4': 'file',
'.webm': 'file',
};

View File

@@ -0,0 +1,65 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
const EXTERNAL_MODULES = [
'@lingui/cli',
'@lingui/conf',
'cosmiconfig',
'jiti',
'node:*',
'crypto',
'path',
'fs',
'os',
'vm',
'perf_hooks',
'util',
'events',
'stream',
'buffer',
'child_process',
'cluster',
'dgram',
'dns',
'http',
'https',
'module',
'net',
'repl',
'tls',
'url',
'worker_threads',
'readline',
'zlib',
'resolve',
];
const EXTERNAL_PATTERNS = [/^node:.*/];
export class ExternalsPlugin {
apply(compiler) {
const existingExternals = compiler.options.externals || [];
const externalsArray = Array.isArray(existingExternals) ? existingExternals : [existingExternals];
compiler.options.externals = [...externalsArray, ...EXTERNAL_MODULES, ...EXTERNAL_PATTERNS];
}
}
export function externalsPlugin() {
return new ExternalsPlugin();
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export function getLinguiSwcPluginConfig() {
return [
'@lingui/swc-plugin',
{
localeDir: 'src/locales/{locale}/messages',
runtimeModules: {
i18n: ['@lingui/core', 'i18n'],
trans: ['@lingui/react', 'Trans'],
},
stripNonEssentialFields: false,
},
];
}
export function createPoFileRule() {
return {
test: /\.po$/,
type: 'javascript/auto',
use: {
loader: path.join(__dirname, 'po-loader.mjs'),
},
};
}

View File

@@ -0,0 +1,164 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import fs from 'node:fs/promises';
export default function poLoader(source) {
const callback = this.async();
(async () => {
try {
this.cacheable?.();
const poPath = this.resourcePath;
const compiledPath = `${poPath}.mjs`;
this.addDependency?.(poPath);
try {
await fs.access(compiledPath);
this.addDependency?.(compiledPath);
const compiledSource = await fs.readFile(compiledPath, 'utf8');
callback(null, compiledSource);
return;
} catch {}
const content = Buffer.isBuffer(source) ? source.toString('utf8') : String(source);
const messages = parsePoFile(content);
const code = `export const messages = ${JSON.stringify(messages, null, 2)};\nexport default messages;\n`;
callback(null, code);
} catch (err) {
callback(err);
}
})();
}
function parsePoFile(content) {
const messages = {};
const entries = splitEntries(content);
for (const entry of entries) {
const parsed = parseEntry(entry);
if (!parsed) continue;
messages[parsed.key] = parsed.value;
}
return messages;
}
function splitEntries(content) {
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
return normalized
.split(/\n{2,}/g)
.map((s) => s.trim())
.filter(Boolean);
}
function parseEntry(entry) {
const lines = entry.split('\n');
let msgctxt = null;
let msgid = null;
let msgidPlural = null;
const msgstrMap = new Map();
let active = null;
let activeMsgstrIndex = 0;
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
if (line.startsWith('msgctxt ')) {
active = 'msgctxt';
activeMsgstrIndex = 0;
msgctxt = extractPoString(line.slice('msgctxt '.length));
continue;
}
if (line.startsWith('msgid_plural ')) {
active = 'msgidPlural';
activeMsgstrIndex = 0;
msgidPlural = extractPoString(line.slice('msgid_plural '.length));
continue;
}
if (line.startsWith('msgid ')) {
active = 'msgid';
activeMsgstrIndex = 0;
msgid = extractPoString(line.slice('msgid '.length));
continue;
}
const msgstrIndexed = line.match(/^msgstr\[(\d+)\]\s+/);
if (msgstrIndexed) {
active = 'msgstr';
activeMsgstrIndex = Number(msgstrIndexed[1]);
const rest = line.slice(msgstrIndexed[0].length);
msgstrMap.set(activeMsgstrIndex, extractPoString(rest));
continue;
}
if (line.startsWith('msgstr ')) {
active = 'msgstr';
activeMsgstrIndex = 0;
msgstrMap.set(0, extractPoString(line.slice('msgstr '.length)));
continue;
}
if (line.startsWith('"') && line.endsWith('"')) {
const part = extractPoString(line);
if (active === 'msgctxt') msgctxt = (msgctxt ?? '') + part;
else if (active === 'msgid') msgid = (msgid ?? '') + part;
else if (active === 'msgidPlural') msgidPlural = (msgidPlural ?? '') + part;
else if (active === 'msgstr') msgstrMap.set(activeMsgstrIndex, (msgstrMap.get(activeMsgstrIndex) ?? '') + part);
}
}
if (msgid == null) return null;
if (msgid === '') return null;
const key = msgctxt ? `${msgctxt}\u0004${msgid}` : msgid;
if (msgidPlural != null) {
const keys = Array.from(msgstrMap.keys());
const maxIndex = keys.length ? Math.max(...keys.map((n) => Number(n))) : 0;
const arr = [];
for (let i = 0; i <= maxIndex; i++) {
arr[i] = msgstrMap.get(i) ?? '';
}
return {key, value: arr};
}
return {key, value: msgstrMap.get(0) ?? ''};
}
function extractPoString(str) {
const trimmed = str.trim();
if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) return trimmed;
return trimmed.slice(1, -1).replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}

View File

@@ -0,0 +1,119 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {sources} from '@rspack/core';
function normalizeEndpoint(cdnEndpoint) {
if (!cdnEndpoint) return '';
return cdnEndpoint.endsWith('/') ? cdnEndpoint.slice(0, -1) : cdnEndpoint;
}
function generateManifest(cdnEndpointRaw) {
const cdnEndpoint = normalizeEndpoint(cdnEndpointRaw);
const manifest = {
name: 'Fluxer',
short_name: 'Fluxer',
description:
'Chat that puts you first. Built for friends, groups, and communities. Text, voice, and video. Open source and community-funded.',
start_url: '/',
display: 'standalone',
orientation: 'portrait-primary',
theme_color: '#4641D9',
background_color: '#2b2d31',
categories: ['social', 'communication'],
lang: 'en',
scope: '/',
icons: [
{
src: `${cdnEndpoint}/web/android-chrome-192x192.png`,
sizes: '192x192',
type: 'image/png',
purpose: 'maskable any',
},
{
src: `${cdnEndpoint}/web/android-chrome-512x512.png`,
sizes: '512x512',
type: 'image/png',
purpose: 'maskable any',
},
{
src: `${cdnEndpoint}/web/apple-touch-icon.png`,
sizes: '180x180',
type: 'image/png',
},
{
src: `${cdnEndpoint}/web/favicon-32x32.png`,
sizes: '32x32',
type: 'image/png',
},
{
src: `${cdnEndpoint}/web/favicon-16x16.png`,
sizes: '16x16',
type: 'image/png',
},
],
};
return JSON.stringify(manifest, null, 2);
}
function generateBrowserConfig(cdnEndpointRaw) {
const cdnEndpoint = normalizeEndpoint(cdnEndpointRaw);
return `<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="${cdnEndpoint}/web/mstile-150x150.png"/>
<TileColor>#4641D9</TileColor>
</tile>
</msapplication>
</browserconfig>`;
}
function generateRobotsTxt() {
return 'User-agent: *\nAllow: /\n';
}
export class StaticFilesPlugin {
constructor(options) {
this.cdnEndpoint = options?.cdnEndpoint ?? '';
}
apply(compiler) {
compiler.hooks.thisCompilation.tap('StaticFilesPlugin', (compilation) => {
compilation.hooks.processAssets.tap(
{
name: 'StaticFilesPlugin',
stage: compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
},
() => {
compilation.emitAsset('manifest.json', new sources.RawSource(generateManifest(this.cdnEndpoint)));
compilation.emitAsset('browserconfig.xml', new sources.RawSource(generateBrowserConfig(this.cdnEndpoint)));
compilation.emitAsset('robots.txt', new sources.RawSource(generateRobotsTxt()));
},
);
});
}
}
export function staticFilesPlugin(options) {
return new StaticFilesPlugin(options);
}

View File

@@ -0,0 +1,63 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import {fileURLToPath} from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default function wasmLoader(_source) {
const callback = this.async();
if (!callback) {
throw new Error('Async loader not supported');
}
const wasmPath = this.resourcePath;
fs.promises
.readFile(wasmPath)
.then((wasmContent) => {
const base64 = wasmContent.toString('base64');
const code = `
const wasmBase64 = "${base64}";
const wasmBinary = Uint8Array.from(atob(wasmBase64), c => c.charCodeAt(0));
export default wasmBinary;
`;
callback(null, code);
})
.catch((err) => {
callback(err);
});
}
export function wasmModuleRule() {
return {
test: /\.wasm$/,
exclude: [/node_modules/],
type: 'javascript/auto',
use: [
{
loader: path.join(__dirname, 'wasm.mjs'),
},
],
};
}

View File

@@ -0,0 +1,20 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"lib": ["ES2023"],
"noEmit": true,
"moduleResolution": "Bundler",
"moduleDetection": "force",
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["./**/*.ts"]
}

82
fluxer_app/scripts/build/types.d.ts vendored Normal file
View File

@@ -0,0 +1,82 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
declare module 'postcss' {
interface ProcessOptions {
from?: string;
to?: string;
map?: boolean | {inline?: boolean; prev?: boolean | string | object; annotation?: boolean | string};
parser?: unknown;
stringifier?: unknown;
syntax?: unknown;
}
interface Result {
css: string;
map?: unknown;
root: unknown;
processor: unknown;
messages: Array<unknown>;
opts: ProcessOptions;
}
interface Plugin {
postcssPlugin: string;
Once?(root: unknown, helpers: unknown): void | Promise<void>;
Root?(root: unknown, helpers: unknown): void | Promise<void>;
RootExit?(root: unknown, helpers: unknown): void | Promise<void>;
AtRule?(atRule: unknown, helpers: unknown): void | Promise<void>;
AtRuleExit?(atRule: unknown, helpers: unknown): void | Promise<void>;
Rule?(rule: unknown, helpers: unknown): void | Promise<void>;
RuleExit?(rule: unknown, helpers: unknown): void | Promise<void>;
Declaration?(declaration: unknown, helpers: unknown): void | Promise<void>;
DeclarationExit?(declaration: unknown, helpers: unknown): void | Promise<void>;
Comment?(comment: unknown, helpers: unknown): void | Promise<void>;
CommentExit?(comment: unknown, helpers: unknown): void | Promise<void>;
}
interface Processor {
process(css: string, options?: ProcessOptions): Promise<Result>;
}
function postcss(plugins?: Array<Plugin | ((options?: unknown) => Plugin)>): Processor;
export default postcss;
}
declare module 'postcss-modules' {
interface PostcssModulesOptions {
localsConvention?:
| 'camelCase'
| 'camelCaseOnly'
| 'dashes'
| 'dashesOnly'
| ((originalClassName: string, generatedClassName: string, inputFile: string) => string);
generateScopedName?: string | ((name: string, filename: string, css: string) => string);
getJSON?(cssFileName: string, json: Record<string, string>, outputFileName?: string): void;
hashPrefix?: string;
scopeBehaviour?: 'global' | 'local';
globalModulePaths?: Array<RegExp>;
root?: string;
}
function postcssModules(options?: PostcssModulesOptions): import('postcss').Plugin;
export default postcssModules;
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import {ASSETS_DIR, DIST_DIR, PKGS_DIR, PUBLIC_DIR} from '../config';
export async function copyPublicAssets(): Promise<void> {
if (!fs.existsSync(PUBLIC_DIR)) {
return;
}
const files = await fs.promises.readdir(PUBLIC_DIR, {recursive: true});
for (const file of files) {
const srcPath = path.join(PUBLIC_DIR, file.toString());
const destPath = path.join(DIST_DIR, file.toString());
const stat = await fs.promises.stat(srcPath);
if (stat.isFile()) {
await fs.promises.mkdir(path.dirname(destPath), {recursive: true});
await fs.promises.copyFile(srcPath, destPath);
}
}
}
export async function copyWasmFiles(): Promise<void> {
const libfluxcoreDir = path.join(PKGS_DIR, 'libfluxcore');
const wasmFile = path.join(libfluxcoreDir, 'libfluxcore_bg.wasm');
if (fs.existsSync(wasmFile)) {
await fs.promises.copyFile(wasmFile, path.join(ASSETS_DIR, 'libfluxcore_bg.wasm'));
}
}
export async function removeUnusedCssAssets(assetsDir: string, keepFiles: Array<string>): Promise<void> {
if (!fs.existsSync(assetsDir)) {
return;
}
const keepNames = new Set<string>();
for (const file of keepFiles) {
const base = path.basename(file);
keepNames.add(base);
if (base.endsWith('.css')) {
keepNames.add(`${base}.map`);
}
}
const entries = await fs.promises.readdir(assetsDir);
for (const entry of entries) {
if (!entry.endsWith('.css') && !entry.endsWith('.css.map')) {
continue;
}
if (keepNames.has(entry)) {
continue;
}
await fs.promises.rm(path.join(assetsDir, entry), {force: true});
}
}

View File

@@ -0,0 +1,147 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import postcss from 'postcss';
import postcssModules from 'postcss-modules';
import {PKGS_DIR, SRC_DIR} from '../config';
const RESERVED_KEYWORDS = new Set([
'break',
'case',
'catch',
'continue',
'debugger',
'default',
'delete',
'do',
'else',
'export',
'extends',
'finally',
'for',
'function',
'if',
'import',
'in',
'instanceof',
'new',
'return',
'super',
'switch',
'this',
'throw',
'try',
'typeof',
'var',
'void',
'while',
'with',
'yield',
'enum',
'implements',
'interface',
'let',
'package',
'private',
'protected',
'public',
'static',
'await',
'class',
'const',
]);
function isValidIdentifier(name: string): boolean {
if (RESERVED_KEYWORDS.has(name)) {
return false;
}
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name);
}
function generateDtsContent(classNames: Record<string, string>): string {
const validClassNames = Object.keys(classNames).filter(isValidIdentifier);
const typeMembers = validClassNames.map((name) => `\treadonly ${name}: string;`).join('\n');
const defaultExportType =
validClassNames.length > 0 ? `{\n${typeMembers}\n\treadonly [key: string]: string;\n}` : 'Record<string, string>';
return `declare const styles: ${defaultExportType};\nexport default styles;\n`;
}
async function findCssModuleFiles(dir: string): Promise<Array<string>> {
const files: Array<string> = [];
async function walk(currentDir: string): Promise<void> {
const entries = await fs.promises.readdir(currentDir, {withFileTypes: true});
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
if (entry.name !== 'node_modules' && entry.name !== 'dist' && entry.name !== '.git') {
await walk(fullPath);
}
} else if (entry.name.endsWith('.module.css')) {
files.push(fullPath);
}
}
}
await walk(dir);
return files;
}
async function generateDtsForFile(cssPath: string): Promise<void> {
const cssContent = await fs.promises.readFile(cssPath, 'utf-8');
let exportedClassNames: Record<string, string> = {};
await postcss([
postcssModules({
localsConvention: 'camelCaseOnly',
generateScopedName: '[name]__[local]___[hash:base64:5]',
getJSON(_cssFileName: string, json: Record<string, string>) {
exportedClassNames = json;
},
}),
]).process(cssContent, {from: cssPath});
const dtsPath = `${cssPath}.d.ts`;
const dtsContent = generateDtsContent(exportedClassNames);
await fs.promises.writeFile(dtsPath, dtsContent);
}
export async function generateCssDtsForFile(cssPath: string): Promise<void> {
if (!cssPath.endsWith('.module.css')) {
return;
}
await generateDtsForFile(cssPath);
}
export async function generateAllCssDts(): Promise<void> {
const srcFiles = await findCssModuleFiles(SRC_DIR);
const pkgsFiles = await findCssModuleFiles(PKGS_DIR);
const allFiles = [...srcFiles, ...pkgsFiles];
console.log(`Generating .d.ts files for ${allFiles.length} CSS modules...`);
await Promise.all(allFiles.map(generateDtsForFile));
console.log(`Generated ${allFiles.length} CSS module type definitions.`);
}

View File

@@ -0,0 +1,94 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import {ASSETS_DIR, CDN_ENDPOINT, ROOT_DIR} from '../config';
interface BuildOutput {
mainScript: string | null;
cssFiles: Array<string>;
jsFiles: Array<string>;
cssBundleFile: string | null;
vendorScripts: Array<string>;
}
interface GenerateHtmlOptions {
buildOutput: BuildOutput;
production: boolean;
}
async function findCssModulesFile(): Promise<string | null> {
if (!fs.existsSync(ASSETS_DIR)) {
return null;
}
const files = await fs.promises.readdir(ASSETS_DIR);
const stylesFiles = files.filter((name) => name.startsWith('styles.') && name.endsWith('.css'));
if (stylesFiles.length === 0) {
return null;
}
let latestFile: string | null = null;
let latestMtime = 0;
for (const fileName of stylesFiles) {
const filePath = path.join(ASSETS_DIR, fileName);
const stats = await fs.promises.stat(filePath);
if (latestFile === null || stats.mtimeMs > latestMtime) {
latestFile = fileName;
latestMtime = stats.mtimeMs;
}
}
return latestFile ? `assets/${latestFile}` : null;
}
export async function generateHtml(options: GenerateHtmlOptions): Promise<string> {
const {buildOutput, production} = options;
const indexHtmlPath = path.join(ROOT_DIR, 'index.html');
let html = await fs.promises.readFile(indexHtmlPath, 'utf-8');
const baseUrl = production ? `${CDN_ENDPOINT}/` : '/';
const cssModulesFile = buildOutput.cssBundleFile ?? (await findCssModulesFile());
const cssFiles = cssModulesFile ? [cssModulesFile] : buildOutput.cssFiles;
const cssLinks = cssFiles.map((file) => `<link rel="stylesheet" href="${baseUrl}${file}">`).join('\n');
const crossOriginAttr = production && baseUrl.startsWith('http') ? ' crossorigin="anonymous"' : '';
const jsScripts = buildOutput.mainScript
? `<script type="module" src="${baseUrl}${buildOutput.mainScript}"${crossOriginAttr}></script>`
: '';
const buildScriptPreload = (file: string): string =>
`<link rel="preload" as="script" href="${baseUrl}${file}"${crossOriginAttr}>`;
const preloadScripts = [
...(buildOutput.vendorScripts ?? []).map(buildScriptPreload),
...buildOutput.jsFiles.filter((file) => !file.includes('messages')).map(buildScriptPreload),
].join('\n');
html = html.replace(/<script type="module" src="\/src\/index\.tsx"><\/script>/, jsScripts);
const headInsert = [cssLinks, preloadScripts].filter(Boolean).join('\n');
html = html.replace('</head>', `${headInsert}\n</head>`);
return html;
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export {copyPublicAssets, copyWasmFiles, removeUnusedCssAssets} from './assets';
export {generateAllCssDts, generateCssDtsForFile} from './css-dts';
export {generateHtml} from './html';
export {tryResolveWithExtensions} from './resolve';
export {buildServiceWorker} from './service-worker';
export {cleanEmptySourceMaps} from './sourcemaps';

View File

@@ -0,0 +1,48 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import {RESOLVE_EXTENSIONS} from '../config';
export function tryResolveWithExtensions(basePath: string): string | null {
if (fs.existsSync(basePath)) {
const stat = fs.statSync(basePath);
if (stat.isFile()) {
return basePath;
}
if (stat.isDirectory()) {
for (const ext of RESOLVE_EXTENSIONS) {
const indexPath = path.join(basePath, `index${ext}`);
if (fs.existsSync(indexPath)) {
return indexPath;
}
}
}
}
for (const ext of RESOLVE_EXTENSIONS) {
const withExt = `${basePath}${ext}`;
if (fs.existsSync(withExt)) {
return withExt;
}
}
return null;
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as path from 'node:path';
import * as esbuild from 'esbuild';
import {DIST_DIR, SRC_DIR} from '../config';
export async function buildServiceWorker(production: boolean): Promise<void> {
await esbuild.build({
entryPoints: [path.join(SRC_DIR, 'sw', 'worker.ts')],
bundle: true,
format: 'iife',
outfile: path.join(DIST_DIR, 'sw.js'),
minify: production,
sourcemap: true,
target: 'esnext',
define: {
__WB_MANIFEST: '[]',
},
});
}

View File

@@ -0,0 +1,86 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.promises.access(filePath);
return true;
} catch {
return false;
}
}
async function traverseDir(dir: string, callback: (filePath: string) => Promise<void>): Promise<void> {
const entries = await fs.promises.readdir(dir, {withFileTypes: true});
await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await traverseDir(entryPath, callback);
return;
}
await callback(entryPath);
}),
);
}
export async function cleanEmptySourceMaps(dir: string): Promise<void> {
if (!(await fileExists(dir))) {
return;
}
await traverseDir(dir, async (filePath) => {
if (!filePath.endsWith('.js.map')) {
return;
}
let parsed: unknown;
try {
const raw = await fs.promises.readFile(filePath, 'utf-8');
parsed = JSON.parse(raw);
} catch {
return;
}
if (typeof parsed !== 'object' || parsed === null) {
return;
}
const sources = (parsed as {sources?: Array<unknown>}).sources ?? [];
if (Array.isArray(sources) && sources.length > 0) {
return;
}
await fs.promises.rm(filePath, {force: true});
const jsPath = filePath.slice(0, -4);
if (!(await fileExists(jsPath))) {
return;
}
const jsContent = await fs.promises.readFile(jsPath, 'utf-8');
const cleaned = jsContent.replace(/(?:\r?\n)?\/\/# sourceMappingURL=.*$/, '');
if (cleaned !== jsContent) {
await fs.promises.writeFile(jsPath, cleaned);
}
});
}

View File

@@ -0,0 +1,290 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
package main
import (
"fmt"
"math"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
type ColorFamily struct {
Hue int `yaml:"hue" json:"hue"`
Saturation int `yaml:"saturation" json:"saturation"`
UseSaturationFactor bool `yaml:"useSaturationFactor" json:"useSaturationFactor"`
}
type ScaleStop struct {
Name string `yaml:"name"`
Position *float64 `yaml:"position"`
}
type Scale struct {
Family string `yaml:"family"`
Range [2]float64 `yaml:"range"`
Curve string `yaml:"curve"`
Stops []ScaleStop `yaml:"stops"`
}
type TokenDef struct {
Name string `yaml:"name,omitempty"`
Scale string `yaml:"scale,omitempty"`
Value string `yaml:"value,omitempty"`
Family string `yaml:"family,omitempty"`
Hue *int `yaml:"hue,omitempty"`
Saturation *int `yaml:"saturation,omitempty"`
Lightness *float64 `yaml:"lightness,omitempty"`
Alpha *float64 `yaml:"alpha,omitempty"`
UseSaturationFactor *bool `yaml:"useSaturationFactor,omitempty"`
}
type Config struct {
Families map[string]ColorFamily `yaml:"families"`
Scales map[string]Scale `yaml:"scales"`
Tokens struct {
Root []TokenDef `yaml:"root"`
Light []TokenDef `yaml:"light"`
Coal []TokenDef `yaml:"coal"`
} `yaml:"tokens"`
}
type OutputToken struct {
Type string `json:"type"`
Name string `json:"name"`
Family string `json:"family,omitempty"`
Hue *int `json:"hue,omitempty"`
Saturation *int `json:"saturation,omitempty"`
Lightness *float64 `json:"lightness,omitempty"`
Alpha *float64 `json:"alpha,omitempty"`
UseSaturationFactor *bool `json:"useSaturationFactor,omitempty"`
Value string `json:"value,omitempty"`
}
func clamp01(value float64) float64 {
return math.Min(1, math.Max(0, value))
}
func applyCurve(curve string, t float64) float64 {
switch curve {
case "easeIn":
return t * t
case "easeOut":
return 1 - (1-t)*(1-t)
case "easeInOut":
if t < 0.5 {
return 2 * t * t
}
return 1 - 2*(1-t)*(1-t)
default:
return t
}
}
func buildScaleTokens(scale Scale) []OutputToken {
lastIndex := float64(max(len(scale.Stops)-1, 1))
tokens := make([]OutputToken, 0, len(scale.Stops))
for i, stop := range scale.Stops {
pos := 0.0
if stop.Position != nil {
pos = clamp01(*stop.Position)
} else {
pos = float64(i) / lastIndex
}
eased := applyCurve(scale.Curve, pos)
lightness := scale.Range[0] + (scale.Range[1]-scale.Range[0])*eased
lightness = math.Round(lightness*1000) / 1000
tokens = append(tokens, OutputToken{
Type: "tone",
Name: stop.Name,
Family: scale.Family,
Lightness: &lightness,
})
}
return tokens
}
func expandTokens(defs []TokenDef, scales map[string]Scale) []OutputToken {
var tokens []OutputToken
for _, def := range defs {
if def.Scale != "" {
scale, ok := scales[def.Scale]
if !ok {
fmt.Fprintf(os.Stderr, "Warning: unknown scale %q\n", def.Scale)
continue
}
tokens = append(tokens, buildScaleTokens(scale)...)
continue
}
if def.Value != "" {
tokens = append(tokens, OutputToken{
Type: "literal",
Name: def.Name,
Value: strings.TrimSpace(def.Value),
})
} else {
tokens = append(tokens, OutputToken{
Type: "tone",
Name: def.Name,
Family: def.Family,
Hue: def.Hue,
Saturation: def.Saturation,
Lightness: def.Lightness,
Alpha: def.Alpha,
UseSaturationFactor: def.UseSaturationFactor,
})
}
}
return tokens
}
func formatNumber(value float64) string {
if value == float64(int(value)) {
return fmt.Sprintf("%d", int(value))
}
s := fmt.Sprintf("%.2f", value)
s = strings.TrimRight(s, "0")
s = strings.TrimRight(s, ".")
return s
}
func formatTone(token OutputToken, families map[string]ColorFamily) string {
var family *ColorFamily
if token.Family != "" {
if f, ok := families[token.Family]; ok {
family = &f
}
}
var hue, saturation int
var lightness float64
var useFactor bool
if token.Hue != nil {
hue = *token.Hue
} else if family != nil {
hue = family.Hue
}
if token.Saturation != nil {
saturation = *token.Saturation
} else if family != nil {
saturation = family.Saturation
}
if token.Lightness != nil {
lightness = *token.Lightness
}
if token.UseSaturationFactor != nil {
useFactor = *token.UseSaturationFactor
} else if family != nil {
useFactor = family.UseSaturationFactor
}
var satStr string
if useFactor {
satStr = fmt.Sprintf("calc(%s%% * var(--saturation-factor))", formatNumber(float64(saturation)))
} else {
satStr = fmt.Sprintf("%s%%", formatNumber(float64(saturation)))
}
if token.Alpha == nil {
return fmt.Sprintf("hsl(%s, %s, %s%%)", formatNumber(float64(hue)), satStr, formatNumber(lightness))
}
return fmt.Sprintf("hsla(%s, %s, %s%%, %s)", formatNumber(float64(hue)), satStr, formatNumber(lightness), formatNumber(*token.Alpha))
}
func formatValue(token OutputToken, families map[string]ColorFamily) string {
if token.Type == "tone" {
return formatTone(token, families)
}
return strings.TrimSpace(token.Value)
}
func renderBlock(selector string, tokens []OutputToken, families map[string]ColorFamily) string {
var lines []string
for _, token := range tokens {
lines = append(lines, fmt.Sprintf("\t%s: %s;", token.Name, formatValue(token, families)))
}
return fmt.Sprintf("%s {\n%s\n}", selector, strings.Join(lines, "\n"))
}
func generateCSS(cfg *Config, rootTokens, lightTokens, coalTokens []OutputToken) string {
header := `/*
* This file is auto-generated by scripts/cmd/generate-color-system.
* Do not edit directly — update color-system.yaml instead.
*/`
blocks := []string{
renderBlock(":root", rootTokens, cfg.Families),
renderBlock(".theme-light", lightTokens, cfg.Families),
renderBlock(".theme-coal", coalTokens, cfg.Families),
}
return header + "\n\n" + strings.Join(blocks, "\n\n") + "\n"
}
func main() {
cwd, _ := os.Getwd()
configPath := filepath.Join(cwd, "color-system.yaml")
data, err := os.ReadFile(configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading config: %v\n", err)
os.Exit(1)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing config: %v\n", err)
os.Exit(1)
}
rootTokens := expandTokens(cfg.Tokens.Root, cfg.Scales)
lightTokens := expandTokens(cfg.Tokens.Light, cfg.Scales)
coalTokens := expandTokens(cfg.Tokens.Coal, cfg.Scales)
parentDir := filepath.Join(cwd, "..")
cssPath := filepath.Join(parentDir, "src", "styles", "generated", "color-system.css")
if err := os.MkdirAll(filepath.Dir(cssPath), 0755); err != nil {
fmt.Fprintf(os.Stderr, "Error creating CSS directory: %v\n", err)
os.Exit(1)
}
css := generateCSS(&cfg, rootTokens, lightTokens, coalTokens)
if err := os.WriteFile(cssPath, []byte(css), 0644); err != nil {
fmt.Fprintf(os.Stderr, "Error writing CSS file: %v\n", err)
os.Exit(1)
}
relCSS, _ := filepath.Rel(parentDir, cssPath)
fmt.Printf("Wrote %s\n", relCSS)
}

View File

@@ -0,0 +1,437 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
package main
import (
"bytes"
"encoding/json"
"fmt"
"image"
"image/draw"
"image/png"
"io"
"math"
"math/rand"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/srwiley/oksvg"
"github.com/srwiley/rasterx"
)
type EmojiSpritesConfig struct {
NonDiversityPerRow int
DiversityPerRow int
PickerPerRow int
PickerCount int
}
var EMOJI_SPRITES = EmojiSpritesConfig{
NonDiversityPerRow: 42,
DiversityPerRow: 10,
PickerPerRow: 11,
PickerCount: 50,
}
const (
EMOJI_SIZE = 32
TWEMOJI_CDN = "https://fluxerstatic.com/emoji"
)
var SPRITE_SCALES = []int{1, 2}
type EmojiObject struct {
Surrogates string `json:"surrogates"`
Skins []struct {
Surrogates string `json:"surrogates"`
} `json:"skins,omitempty"`
}
type EmojiEntry struct {
Surrogates string
}
type httpResp struct {
Status int
Body string
}
func main() {
rand.Seed(time.Now().UnixNano())
cwd, _ := os.Getwd()
appDir := filepath.Join(cwd, "..")
outputDir := filepath.Join(appDir, "src", "assets", "emoji-sprites")
if err := os.MkdirAll(outputDir, 0o755); err != nil {
fmt.Fprintln(os.Stderr, "Failed to ensure output dir:", err)
os.Exit(1)
}
emojiData, err := loadEmojiData(filepath.Join(appDir, "src", "data", "emojis.json"))
if err != nil {
fmt.Fprintln(os.Stderr, "Error loading emoji data:", err)
os.Exit(1)
}
client := &http.Client{
Timeout: 30 * time.Second,
}
svgCache := newSVGCache()
if err := generateMainSpriteSheet(client, svgCache, emojiData, outputDir); err != nil {
fmt.Fprintln(os.Stderr, "Error generating main sprite sheet:", err)
os.Exit(1)
}
if err := generateDiversitySpriteSheets(client, svgCache, emojiData, outputDir); err != nil {
fmt.Fprintln(os.Stderr, "Error generating diversity sprite sheets:", err)
os.Exit(1)
}
if err := generatePickerSpriteSheet(client, svgCache, outputDir); err != nil {
fmt.Fprintln(os.Stderr, "Error generating picker sprite sheet:", err)
os.Exit(1)
}
fmt.Println("Emoji sprites generated successfully.")
}
func loadEmojiData(path string) (map[string][]EmojiObject, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var data map[string][]EmojiObject
if err := json.Unmarshal(b, &data); err != nil {
return nil, err
}
return data, nil
}
// --- SVG fetching + caching ---
type svgCache struct {
m map[string]*string
}
func newSVGCache() *svgCache {
return &svgCache{m: make(map[string]*string)}
}
func (c *svgCache) get(codepoint string) (*string, bool) {
v, ok := c.m[codepoint]
return v, ok
}
func (c *svgCache) set(codepoint string, v *string) {
c.m[codepoint] = v
}
func downloadSVG(client *http.Client, url string) (httpResp, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return httpResp{}, err
}
req.Header.Set("User-Agent", "fluxer-emoji-sprites/1.0")
resp, err := client.Do(req)
if err != nil {
return httpResp{}, err
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return httpResp{}, err
}
return httpResp{
Status: resp.StatusCode,
Body: string(bodyBytes),
}, nil
}
func fetchTwemojiSVG(client *http.Client, cache *svgCache, codepoint string) *string {
if v, ok := cache.get(codepoint); ok {
return v
}
url := fmt.Sprintf("%s/%s.svg", TWEMOJI_CDN, codepoint)
r, err := downloadSVG(client, url)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch Twemoji %s: %v\n", codepoint, err)
cache.set(codepoint, nil)
return nil
}
if r.Status != 200 {
fmt.Fprintf(os.Stderr, "Twemoji %s returned %d\n", codepoint, r.Status)
cache.set(codepoint, nil)
return nil
}
body := r.Body
cache.set(codepoint, &body)
return &body
}
// --- Emoji -> codepoint ---
func emojiToCodepoint(s string) string {
parts := make([]string, 0, len(s))
for _, r := range s {
if r == 0xFE0F {
continue
}
parts = append(parts, strings.ToLower(strconv.FormatInt(int64(r), 16)))
}
return strings.Join(parts, "-")
}
// --- Rendering ---
var svgOpenTagRe = regexp.MustCompile(`(?i)<svg([^>]*)>`)
func fixSVGSize(svg string, size int) string {
return svgOpenTagRe.ReplaceAllString(svg, fmt.Sprintf(`<svg$1 width="%d" height="%d">`, size, size))
}
func renderSVGToImage(svgContent string, size int) (*image.RGBA, error) {
fixed := fixSVGSize(svgContent, size)
icon, err := oksvg.ReadIconStream(strings.NewReader(fixed))
if err != nil {
return nil, err
}
icon.SetTarget(0, 0, float64(size), float64(size))
dst := image.NewRGBA(image.Rect(0, 0, size, size))
scanner := rasterx.NewScannerGV(size, size, dst, dst.Bounds())
r := rasterx.NewDasher(size, size, scanner)
icon.Draw(r, 1.0)
return dst, nil
}
func createPlaceholder(size int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, size, size))
h := rand.Float64() * 360.0
r, g, b := hslToRGB(h, 0.70, 0.60)
cx := float64(size) / 2.0
cy := float64(size) / 2.0
radius := float64(size) * 0.4
r2 := radius * radius
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
dx := (float64(x) + 0.5) - cx
dy := (float64(y) + 0.5) - cy
if dx*dx+dy*dy <= r2 {
i := img.PixOffset(x, y)
img.Pix[i+0] = r
img.Pix[i+1] = g
img.Pix[i+2] = b
img.Pix[i+3] = 0xFF
}
}
}
return img
}
func hslToRGB(h, s, l float64) (uint8, uint8, uint8) {
h = math.Mod(h, 360.0) / 360.0
var r, g, b float64
if s == 0 {
r, g, b = l, l, l
} else {
var q float64
if l < 0.5 {
q = l * (1 + s)
} else {
q = l + s - l*s
}
p := 2*l - q
r = hueToRGB(p, q, h+1.0/3.0)
g = hueToRGB(p, q, h)
b = hueToRGB(p, q, h-1.0/3.0)
}
return uint8(clamp01(r) * 255), uint8(clamp01(g) * 255), uint8(clamp01(b) * 255)
}
func hueToRGB(p, q, t float64) float64 {
if t < 0 {
t += 1
}
if t > 1 {
t -= 1
}
if t < 1.0/6.0 {
return p + (q-p)*6*t
}
if t < 1.0/2.0 {
return q
}
if t < 2.0/3.0 {
return p + (q-p)*(2.0/3.0-t)*6
}
return p
}
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
func loadEmojiImage(client *http.Client, cache *svgCache, surrogate string, size int) *image.RGBA {
codepoint := emojiToCodepoint(surrogate)
if svg := fetchTwemojiSVG(client, cache, codepoint); svg != nil {
if img, err := renderSVGToImage(*svg, size); err == nil {
return img
}
}
if strings.Contains(codepoint, "-200d-") {
basePart := strings.Split(codepoint, "-200d-")[0]
if svg := fetchTwemojiSVG(client, cache, basePart); svg != nil {
if img, err := renderSVGToImage(*svg, size); err == nil {
return img
}
}
}
fmt.Fprintf(os.Stderr, "Missing SVG for %s (%s), using placeholder\n", codepoint, surrogate)
return createPlaceholder(size)
}
func renderSpriteSheet(client *http.Client, cache *svgCache, emojiEntries []EmojiEntry, perRow int, fileNameBase string, outputDir string) error {
if perRow <= 0 {
return fmt.Errorf("perRow must be > 0")
}
rows := int(math.Ceil(float64(len(emojiEntries)) / float64(perRow)))
for _, scale := range SPRITE_SCALES {
size := EMOJI_SIZE * scale
dstW := perRow * size
dstH := rows * size
sheet := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
for i, item := range emojiEntries {
emojiImg := loadEmojiImage(client, cache, item.Surrogates, size)
row := i / perRow
col := i % perRow
x := col * size
y := row * size
r := image.Rect(x, y, x+size, y+size)
draw.Draw(sheet, r, emojiImg, image.Point{}, draw.Over)
}
suffix := ""
if scale != 1 {
suffix = fmt.Sprintf("@%dx", scale)
}
outPath := filepath.Join(outputDir, fmt.Sprintf("%s%s.png", fileNameBase, suffix))
if err := writePNG(outPath, sheet); err != nil {
return err
}
fmt.Printf("Wrote %s\n", outPath)
}
return nil
}
func writePNG(path string, img image.Image) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return err
}
return os.WriteFile(path, buf.Bytes(), 0o644)
}
// --- Generators ---
func generateMainSpriteSheet(client *http.Client, cache *svgCache, emojiData map[string][]EmojiObject, outputDir string) error {
base := make([]EmojiEntry, 0, 4096)
for _, objs := range emojiData {
for _, obj := range objs {
base = append(base, EmojiEntry{Surrogates: obj.Surrogates})
}
}
return renderSpriteSheet(client, cache, base, EMOJI_SPRITES.NonDiversityPerRow, "spritesheet-emoji", outputDir)
}
func generateDiversitySpriteSheets(client *http.Client, cache *svgCache, emojiData map[string][]EmojiObject, outputDir string) error {
skinTones := []string{"🏻", "🏼", "🏽", "🏾", "🏿"}
for skinIndex, skinTone := range skinTones {
skinCodepoint := emojiToCodepoint(skinTone)
skinEntries := make([]EmojiEntry, 0, 2048)
for _, objs := range emojiData {
for _, obj := range objs {
if len(obj.Skins) > skinIndex && obj.Skins[skinIndex].Surrogates != "" {
skinEntries = append(skinEntries, EmojiEntry{Surrogates: obj.Skins[skinIndex].Surrogates})
}
}
}
if len(skinEntries) == 0 {
continue
}
if err := renderSpriteSheet(client, cache, skinEntries, EMOJI_SPRITES.DiversityPerRow, "spritesheet-"+skinCodepoint, outputDir); err != nil {
return err
}
}
return nil
}
func generatePickerSpriteSheet(client *http.Client, cache *svgCache, outputDir string) error {
basicEmojis := []string{
"😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣", "😊", "😇",
"🙂", "😉", "😌", "😍", "🥰", "😘", "😗", "😙", "😚", "😋",
"😛", "😝", "😜", "🤪", "🤨", "🧐", "🤓", "😎", "🥳", "😏",
}
entries := make([]EmojiEntry, 0, len(basicEmojis))
for _, e := range basicEmojis {
entries = append(entries, EmojiEntry{Surrogates: e})
}
return renderSpriteSheet(client, cache, entries, EMOJI_SPRITES.PickerPerRow, "spritesheet-picker", outputDir)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,279 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
package main
import (
"bufio"
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
type POFile struct {
HeaderLines []string
Entries []POEntry
}
type POEntry struct {
Comments []string
References []string
MsgID string
MsgStr string
}
func main() {
localesDir := flag.String("locales-dir", "../../../../src/locales", "Path to the locales directory")
singleLocale := flag.String("locale", "", "Reset only this locale (empty = all)")
dryRun := flag.Bool("dry-run", false, "Show what would be reset without making changes")
flag.Parse()
absLocalesDir, err := absPath(*localesDir)
if err != nil {
fmt.Printf("Failed to resolve locales directory: %v\n", err)
os.Exit(1)
}
locales, err := discoverLocales(absLocalesDir)
if err != nil {
fmt.Printf("Failed to discover locales: %v\n", err)
os.Exit(1)
}
var targetLocales []string
for _, locale := range locales {
if locale == "en-US" {
continue
}
if *singleLocale != "" && locale != *singleLocale {
continue
}
targetLocales = append(targetLocales, locale)
}
if len(targetLocales) == 0 {
fmt.Println("No target locales found")
os.Exit(1)
}
fmt.Printf("Resetting translations for %d locales...\n", len(targetLocales))
if *dryRun {
fmt.Println("(DRY RUN - no changes will be made)")
}
fmt.Println()
totalReset := 0
for _, locale := range targetLocales {
poPath := filepath.Join(absLocalesDir, locale, "messages.po")
poFile, err := parsePOFile(poPath)
if err != nil {
fmt.Printf(" ✗ %s: failed to parse: %v\n", locale, err)
continue
}
resetCount := 0
for i := range poFile.Entries {
if poFile.Entries[i].MsgStr != "" {
resetCount++
poFile.Entries[i].MsgStr = ""
}
}
if resetCount == 0 {
fmt.Printf(" - %s: already empty (0 strings)\n", locale)
continue
}
if !*dryRun {
if err := writePOFile(poPath, poFile); err != nil {
fmt.Printf(" ✗ %s: failed to write: %v\n", locale, err)
continue
}
}
fmt.Printf(" ✓ %s: reset %d strings\n", locale, resetCount)
totalReset += resetCount
}
fmt.Printf("\nTotal: reset %d translations across %d locales\n", totalReset, len(targetLocales))
if *dryRun {
fmt.Println("(DRY RUN - run without --dry-run to apply changes)")
}
}
func absPath(rel string) (string, error) {
if filepath.IsAbs(rel) {
return rel, nil
}
wd, err := os.Getwd()
if err != nil {
return "", err
}
return filepath.Join(wd, rel), nil
}
func discoverLocales(localesDir string) ([]string, error) {
entries, err := os.ReadDir(localesDir)
if err != nil {
return nil, err
}
var locales []string
for _, entry := range entries {
if entry.IsDir() {
locales = append(locales, entry.Name())
}
}
sort.Strings(locales)
return locales, nil
}
func parsePOFile(path string) (POFile, error) {
file, err := os.Open(path)
if err != nil {
return POFile{}, err
}
defer file.Close()
var (
current []string
scanner = bufio.NewScanner(file)
trimmed string
headerSet bool
result POFile
)
for scanner.Scan() {
line := scanner.Text()
trimmed = strings.TrimSpace(line)
if trimmed == "" {
if len(current) > 0 {
entry := parseBlock(current)
if !headerSet && entry.MsgID == "" {
result.HeaderLines = append([]string{}, current...)
headerSet = true
} else {
result.Entries = append(result.Entries, entry)
}
current = nil
}
continue
}
current = append(current, line)
}
if len(current) > 0 {
entry := parseBlock(current)
if !headerSet && entry.MsgID == "" {
result.HeaderLines = append([]string{}, current...)
} else {
result.Entries = append(result.Entries, entry)
}
}
if err := scanner.Err(); err != nil {
return POFile{}, err
}
return result, nil
}
func parseBlock(lines []string) POEntry {
entry := POEntry{}
var (
inMsgID bool
inMsgStr bool
)
for _, raw := range lines {
line := strings.TrimSpace(raw)
switch {
case strings.HasPrefix(line, "#."):
entry.Comments = append(entry.Comments, strings.TrimSpace(line[2:]))
case strings.HasPrefix(line, "#:"):
entry.References = append(entry.References, strings.TrimSpace(line[2:]))
case strings.HasPrefix(line, "msgid"):
entry.MsgID = parseQuoted(strings.TrimSpace(line[len("msgid"):]))
inMsgID = true
inMsgStr = false
case strings.HasPrefix(line, "msgstr"):
entry.MsgStr = parseQuoted(strings.TrimSpace(line[len("msgstr"):]))
inMsgStr = true
inMsgID = false
case strings.HasPrefix(line, "\""):
if inMsgID {
entry.MsgID += parseQuoted(line)
} else if inMsgStr {
entry.MsgStr += parseQuoted(line)
}
}
}
return entry
}
func parseQuoted(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if unquoted, err := strconv.Unquote(value); err == nil {
return unquoted
}
if strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"") {
return value[1 : len(value)-1]
}
return value
}
func writePOFile(path string, po POFile) error {
var lines []string
if len(po.HeaderLines) > 0 {
lines = append(lines, po.HeaderLines...)
lines = append(lines, "")
}
for idx, entry := range po.Entries {
lines = append(lines, renderEntry(entry))
if idx < len(po.Entries)-1 {
lines = append(lines, "")
}
}
lines = append(lines, "")
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644)
}
func renderEntry(entry POEntry) string {
var sb strings.Builder
for _, comment := range entry.Comments {
sb.WriteString("#. ")
sb.WriteString(comment)
sb.WriteString("\n")
}
for _, ref := range entry.References {
sb.WriteString("#: ")
sb.WriteString(ref)
sb.WriteString("\n")
}
sb.WriteString("msgid ")
sb.WriteString(strconv.Quote(entry.MsgID))
sb.WriteString("\nmsgstr ")
sb.WriteString(strconv.Quote(entry.MsgStr))
return sb.String()
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
package main
import (
"fmt"
"os"
"path/filepath"
"slices"
)
var allowedChannels = []string{"stable", "canary"}
func parseChannel() string {
raw := os.Getenv("BUILD_CHANNEL")
if raw != "" && slices.Contains(allowedChannels, raw) {
return raw
}
return "stable"
}
func main() {
channel := parseChannel()
cwd, _ := os.Getwd()
targetPath := filepath.Join(cwd, "..", "src-electron", "common", "build-channel.ts")
fileContent := fmt.Sprintf(`/*
* This file is generated by scripts/cmd/set-build-channel.
* DO NOT EDIT MANUALLY.
*/
export type BuildChannel = 'stable' | 'canary';
const DEFAULT_BUILD_CHANNEL = '%s' as BuildChannel;
const envChannel = process.env.BUILD_CHANNEL?.toLowerCase();
export const BUILD_CHANNEL = (envChannel === 'canary' ? 'canary' : DEFAULT_BUILD_CHANNEL) as BuildChannel;
export const IS_CANARY = BUILD_CHANNEL === 'canary';
export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;
`, channel)
if err := os.WriteFile(targetPath, []byte(fileContent), 0644); err != nil {
fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
os.Exit(1)
}
fmt.Printf("Wrote %s with channel '%s'\n", targetPath, channel)
}

View File

@@ -0,0 +1,418 @@
families:
neutralDark:
hue: 220
saturation: 13
useSaturationFactor: true
neutralLight:
hue: 210
saturation: 20
useSaturationFactor: true
brand:
hue: 242
saturation: 70
useSaturationFactor: true
link:
hue: 210
saturation: 100
useSaturationFactor: true
accentPurple:
hue: 270
saturation: 80
useSaturationFactor: true
statusOnline:
hue: 142
saturation: 76
useSaturationFactor: true
statusIdle:
hue: 45
saturation: 93
useSaturationFactor: true
statusDnd:
hue: 0
saturation: 84
useSaturationFactor: true
statusOffline:
hue: 218
saturation: 11
useSaturationFactor: true
statusDanger:
hue: 1
saturation: 77
useSaturationFactor: true
textCode:
hue: 340
saturation: 50
useSaturationFactor: true
brandIcon:
hue: 38
saturation: 92
useSaturationFactor: true
scales:
darkSurface:
family: neutralDark
range: [5, 26]
curve: easeOut
stops:
- { name: "--background-primary", position: 0 }
- { name: "--background-secondary", position: 0.16 }
- { name: "--background-secondary-lighter", position: 0.22 }
- { name: "--background-secondary-alt", position: 0.28 }
- { name: "--background-tertiary", position: 0.4 }
- { name: "--background-channel-header", position: 0.34 }
- { name: "--guild-list-foreground", position: 0.38 }
- { name: "--background-header-secondary", position: 0.5 }
- { name: "--background-header-primary", position: 0.5 }
- { name: "--background-textarea", position: 0.68 }
- { name: "--background-header-primary-hover", position: 0.85 }
coalSurface:
family: neutralDark
range: [1, 12]
curve: easeOut
stops:
- { name: "--background-primary", position: 0 }
- { name: "--background-secondary", position: 0.16 }
- { name: "--background-secondary-alt", position: 0.28 }
- { name: "--background-tertiary", position: 0.4 }
- { name: "--background-channel-header", position: 0.34 }
- { name: "--guild-list-foreground", position: 0.38 }
- { name: "--background-header-secondary", position: 0.5 }
- { name: "--background-header-primary", position: 0.5 }
- { name: "--background-textarea", position: 0.68 }
- { name: "--background-header-primary-hover", position: 0.85 }
darkText:
family: neutralDark
range: [52, 96]
curve: easeInOut
stops:
- { name: "--text-tertiary-secondary", position: 0 }
- { name: "--text-tertiary-muted", position: 0.2 }
- { name: "--text-tertiary", position: 0.38 }
- { name: "--text-primary-muted", position: 0.55 }
- { name: "--text-chat-muted", position: 0.55 }
- { name: "--text-secondary", position: 0.72 }
- { name: "--text-chat", position: 0.82 }
- { name: "--text-primary", position: 1 }
lightSurface:
family: neutralLight
range: [85, 99]
curve: easeIn
stops:
- { name: "--background-header-primary-hover", position: 0 }
- { name: "--background-header-primary", position: 0.12 }
- { name: "--background-header-secondary", position: 0.2 }
- { name: "--guild-list-foreground", position: 0.35 }
- { name: "--background-tertiary", position: 0.42 }
- { name: "--background-channel-header", position: 0.5 }
- { name: "--background-secondary-alt", position: 0.63 }
- { name: "--background-textarea", position: 0.88 }
- { name: "--background-primary", position: 1 }
lightText:
family: neutralLight
range: [15, 60]
curve: easeOut
stops:
- { name: "--text-primary", position: 0 }
- { name: "--text-chat", position: 0.08 }
- { name: "--text-secondary", position: 0.28 }
- { name: "--text-chat-muted", position: 0.45 }
- { name: "--text-primary-muted", position: 0.45 }
- { name: "--text-tertiary", position: 0.6 }
- { name: "--text-tertiary-secondary", position: 0.75 }
- { name: "--text-tertiary-muted", position: 0.85 }
tokens:
root:
- scale: darkSurface
- scale: darkText
- name: "--panel-control-bg"
value: |
color-mix(
in srgb,
var(--background-secondary-alt) 80%,
hsl(220, calc(13% * var(--saturation-factor)), 2%) 20%
)
- { name: "--panel-control-border", family: neutralDark, saturation: 30, lightness: 65, alpha: 0.45 }
- { name: "--panel-control-divider", family: neutralDark, saturation: 30, lightness: 55, alpha: 0.35 }
- { name: "--panel-control-highlight", value: "hsla(0, 0%, 100%, 0.04)" }
- { name: "--background-modifier-hover", family: neutralDark, lightness: 100, alpha: 0.05 }
- { name: "--background-modifier-selected", family: neutralDark, lightness: 100, alpha: 0.1 }
- { name: "--background-modifier-accent", family: neutralDark, saturation: 13, lightness: 80, alpha: 0.15 }
- { name: "--background-modifier-accent-focus", family: neutralDark, saturation: 13, lightness: 80, alpha: 0.22 }
- { name: "--control-button-normal-bg", value: "transparent" }
- { name: "--control-button-normal-text", value: "var(--text-primary-muted)" }
- { name: "--control-button-hover-bg", family: neutralDark, lightness: 22 }
- { name: "--control-button-hover-text", value: "var(--text-primary)" }
- { name: "--control-button-active-bg", family: neutralDark, lightness: 24 }
- { name: "--control-button-active-text", value: "var(--text-primary)" }
- { name: "--control-button-danger-text", hue: 1, saturation: 77, useSaturationFactor: true, lightness: 60 }
- { name: "--control-button-danger-hover-bg", hue: 1, saturation: 77, useSaturationFactor: true, lightness: 20 }
- { name: "--brand-primary", family: brand, lightness: 55 }
- { name: "--brand-secondary", family: brand, saturation: 60, lightness: 49 }
- { name: "--brand-primary-light", family: brand, saturation: 100, lightness: 84 }
- { name: "--brand-primary-fill", hue: 0, saturation: 0, lightness: 100 }
- { name: "--status-online", family: statusOnline, lightness: 40 }
- { name: "--status-idle", family: statusIdle, lightness: 50 }
- { name: "--status-dnd", family: statusDnd, lightness: 60 }
- { name: "--status-offline", family: statusOffline, lightness: 65 }
- { name: "--status-danger", family: statusDanger, lightness: 55 }
- { name: "--status-warning", value: "var(--status-idle)" }
- { name: "--text-warning", family: statusIdle, lightness: 55 }
- { name: "--plutonium", value: "var(--brand-primary)" }
- { name: "--plutonium-hover", value: "var(--brand-secondary)" }
- { name: "--plutonium-text", value: "var(--text-on-brand-primary)" }
- { name: "--plutonium-icon", family: brandIcon, lightness: 50 }
- { name: "--invite-verified-icon-color", value: "var(--text-on-brand-primary)" }
- { name: "--text-link", family: link, lightness: 70 }
- { name: "--text-on-brand-primary", hue: 0, saturation: 0, lightness: 98 }
- { name: "--text-code", family: textCode, lightness: 90 }
- { name: "--text-selection", hue: 210, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.35 }
- { name: "--markup-mention-text", value: "var(--text-link)" }
- name: "--markup-mention-fill"
value: "color-mix(in srgb, var(--text-link) 20%, transparent)"
- { name: "--markup-mention-border", family: link, lightness: 70, alpha: 0.3 }
- { name: "--markup-jump-link-text", value: "var(--text-link)" }
- name: "--markup-jump-link-fill"
value: "color-mix(in srgb, var(--text-link) 12%, transparent)"
- name: "--markup-jump-link-hover-fill"
value: "color-mix(in srgb, var(--text-link) 20%, transparent)"
- { name: "--markup-everyone-text", hue: 250, saturation: 80, useSaturationFactor: true, lightness: 75 }
- name: "--markup-everyone-fill"
value: "color-mix(in srgb, hsl(250, calc(80% * var(--saturation-factor)), 75%) 18%, transparent)"
- { name: "--markup-everyone-border", hue: 250, saturation: 80, useSaturationFactor: true, lightness: 75, alpha: 0.3 }
- { name: "--markup-here-text", hue: 45, saturation: 90, useSaturationFactor: true, lightness: 70 }
- name: "--markup-here-fill"
value: "color-mix(in srgb, hsl(45, calc(90% * var(--saturation-factor)), 70%) 18%, transparent)"
- { name: "--markup-here-border", hue: 45, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.3 }
- { name: "--markup-interactive-hover-text", value: "var(--text-link)" }
- name: "--markup-interactive-hover-fill"
value: "color-mix(in srgb, var(--text-link) 30%, transparent)"
- name: "--interactive-muted"
value: |
color-mix(
in oklab,
hsl(228, calc(10% * var(--saturation-factor)), 35%) 100%,
hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
)
- name: "--interactive-active"
value: |
color-mix(
in oklab,
hsl(0, calc(0% * var(--saturation-factor)), 100%) 100%,
hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
)
- { name: "--button-primary-fill", hue: 139, saturation: 55, useSaturationFactor: true, lightness: 44 }
- { name: "--button-primary-active-fill", hue: 136, saturation: 60, useSaturationFactor: true, lightness: 38 }
- { name: "--button-primary-text", hue: 0, saturation: 0, lightness: 100 }
- { name: "--button-secondary-fill", hue: 0, saturation: 0, lightness: 100, alpha: 0.1, useSaturationFactor: false }
- { name: "--button-secondary-active-fill", hue: 0, saturation: 0, lightness: 100, alpha: 0.15, useSaturationFactor: false }
- { name: "--button-secondary-text", hue: 0, saturation: 0, lightness: 100 }
- { name: "--button-secondary-active-text", value: "var(--button-secondary-text)" }
- { name: "--button-danger-fill", hue: 359, saturation: 70, useSaturationFactor: true, lightness: 54 }
- { name: "--button-danger-active-fill", hue: 359, saturation: 65, useSaturationFactor: true, lightness: 45 }
- { name: "--button-danger-text", hue: 0, saturation: 0, lightness: 100 }
- { name: "--button-danger-outline-border", value: "1px solid hsl(359, calc(70% * var(--saturation-factor)), 54%)" }
- { name: "--button-danger-outline-text", hue: 0, saturation: 0, lightness: 100 }
- { name: "--button-danger-outline-active-fill", hue: 359, saturation: 65, useSaturationFactor: true, lightness: 48 }
- { name: "--button-danger-outline-active-border", value: "transparent" }
- { name: "--button-ghost-text", hue: 0, saturation: 0, lightness: 100 }
- { name: "--button-inverted-fill", hue: 0, saturation: 0, lightness: 100 }
- { name: "--button-inverted-text", hue: 0, saturation: 0, lightness: 0 }
- { name: "--button-outline-border", value: "1px solid hsla(0, 0%, 100%, 0.3)" }
- { name: "--button-outline-text", hue: 0, saturation: 0, lightness: 100 }
- { name: "--button-outline-active-fill", value: "hsla(0, 0%, 100%, 0.15)" }
- { name: "--button-outline-active-border", value: "1px solid hsla(0, 0%, 100%, 0.4)" }
- { name: "--theme-border", value: "transparent" }
- { name: "--theme-border-width", value: "0px" }
- { name: "--bg-primary", value: "var(--background-primary)" }
- { name: "--bg-secondary", value: "var(--background-secondary)" }
- { name: "--bg-tertiary", value: "var(--background-tertiary)" }
- { name: "--bg-hover", value: "var(--background-modifier-hover)" }
- { name: "--bg-active", value: "var(--background-modifier-selected)" }
- { name: "--bg-code", family: neutralDark, lightness: 15, alpha: 0.8 }
- { name: "--bg-code-block", value: "var(--background-secondary-alt)" }
- { name: "--bg-blockquote", value: "var(--background-secondary-alt)" }
- { name: "--bg-table-header", value: "var(--background-tertiary)" }
- { name: "--bg-table-row-odd", value: "var(--background-primary)" }
- { name: "--bg-table-row-even", value: "var(--background-secondary)" }
- { name: "--border-color", family: neutralDark, lightness: 50, alpha: 0.2 }
- { name: "--border-color-hover", family: neutralDark, lightness: 50, alpha: 0.3 }
- { name: "--border-color-focus", hue: 210, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.45 }
- { name: "--accent-primary", value: "var(--brand-primary)" }
- { name: "--accent-success", value: "var(--status-online)" }
- { name: "--accent-warning", value: "var(--status-idle)" }
- { name: "--accent-danger", value: "var(--status-dnd)" }
- { name: "--accent-info", value: "var(--text-link)" }
- { name: "--accent-purple", family: accentPurple, lightness: 65 }
- { name: "--alert-note-color", family: link, lightness: 70 }
- { name: "--alert-tip-color", family: statusOnline, lightness: 45 }
- { name: "--alert-important-color", family: accentPurple, lightness: 65 }
- { name: "--alert-warning-color", family: statusIdle, lightness: 55 }
- { name: "--alert-caution-color", hue: 359, saturation: 75, useSaturationFactor: true, lightness: 60 }
- { name: "--shadow-sm", value: "0 1px 2px rgba(0, 0, 0, 0.1)" }
- { name: "--shadow-md", value: "0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.1)" }
- { name: "--shadow-lg", value: "0 4px 8px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.1)" }
- { name: "--shadow-xl", value: "0 10px 20px rgba(0, 0, 0, 0.15), 0 4px 8px rgba(0, 0, 0, 0.1)" }
- { name: "--transition-fast", value: "100ms ease" }
- { name: "--transition-normal", value: "200ms ease" }
- { name: "--transition-slow", value: "300ms ease" }
- { name: "--spoiler-overlay-color", value: "rgba(0, 0, 0, 0.2)" }
- { name: "--spoiler-overlay-hover-color", value: "rgba(0, 0, 0, 0.3)" }
- { name: "--scrollbar-thumb-bg", value: "rgba(121, 122, 124, 0.4)" }
- { name: "--scrollbar-thumb-bg-hover", value: "rgba(121, 122, 124, 0.7)" }
- { name: "--scrollbar-track-bg", value: "transparent" }
- { name: "--user-area-divider-color", value: "color-mix(in srgb, var(--background-modifier-hover) 70%, transparent)" }
light:
- scale: lightSurface
- scale: lightText
- { name: "--background-secondary", value: "var(--background-primary)" }
- { name: "--background-secondary-lighter", value: "var(--background-secondary)" }
- { name: "--panel-control-bg", value: "color-mix(in srgb, var(--background-secondary) 65%, hsl(0, 0%, 100%) 35%)" }
- { name: "--panel-control-border", family: neutralLight, saturation: 25, lightness: 45, alpha: 0.25 }
- { name: "--panel-control-divider", family: neutralLight, saturation: 30, lightness: 35, alpha: 0.2 }
- { name: "--panel-control-highlight", value: "hsla(0, 0%, 100%, 0.65)" }
- { name: "--background-modifier-hover", family: neutralLight, saturation: 20, lightness: 10, alpha: 0.04 }
- { name: "--background-modifier-selected", family: neutralLight, saturation: 20, lightness: 10, alpha: 0.08 }
- { name: "--background-modifier-accent", family: neutralLight, saturation: 20, lightness: 40, alpha: 0.2 }
- { name: "--background-modifier-accent-focus", family: neutralLight, saturation: 20, lightness: 40, alpha: 0.3 }
- { name: "--control-button-normal-bg", value: "transparent" }
- { name: "--control-button-normal-text", family: neutralLight, lightness: 50 }
- { name: "--control-button-hover-bg", family: neutralLight, lightness: 88 }
- { name: "--control-button-hover-text", family: neutralLight, lightness: 20 }
- { name: "--control-button-active-bg", family: neutralLight, lightness: 85 }
- { name: "--control-button-active-text", family: neutralLight, lightness: 15 }
- { name: "--control-button-danger-text", hue: 359, saturation: 70, useSaturationFactor: true, lightness: 50 }
- { name: "--control-button-danger-hover-bg", hue: 359, saturation: 70, useSaturationFactor: true, lightness: 95 }
- { name: "--text-link", family: link, lightness: 45 }
- { name: "--text-code", family: textCode, lightness: 45 }
- { name: "--text-selection", hue: 210, saturation: 90, useSaturationFactor: true, lightness: 50, alpha: 0.2 }
- { name: "--markup-mention-border", family: link, lightness: 45, alpha: 0.4 }
- name: "--markup-jump-link-fill"
value: "color-mix(in srgb, var(--text-link) 8%, transparent)"
- { name: "--markup-everyone-text", hue: 250, saturation: 70, useSaturationFactor: true, lightness: 45 }
- name: "--markup-everyone-fill"
value: "color-mix(in srgb, hsl(250, calc(70% * var(--saturation-factor)), 45%) 12%, transparent)"
- { name: "--markup-everyone-border", hue: 250, saturation: 70, useSaturationFactor: true, lightness: 45, alpha: 0.4 }
- { name: "--markup-here-text", hue: 40, saturation: 85, useSaturationFactor: true, lightness: 40 }
- name: "--markup-here-fill"
value: "color-mix(in srgb, hsl(40, calc(85% * var(--saturation-factor)), 40%) 12%, transparent)"
- { name: "--markup-here-border", hue: 40, saturation: 85, useSaturationFactor: true, lightness: 40, alpha: 0.4 }
- { name: "--status-online", family: statusOnline, saturation: 70, lightness: 40 }
- { name: "--status-idle", family: statusIdle, saturation: 90, lightness: 45 }
- { name: "--status-dnd", hue: 359, saturation: 70, useSaturationFactor: true, lightness: 50 }
- { name: "--status-offline", family: statusOffline, hue: 210, saturation: 10, lightness: 55 }
- { name: "--plutonium", value: "var(--brand-primary)" }
- { name: "--plutonium-hover", value: "var(--brand-secondary)" }
- { name: "--plutonium-text", value: "var(--text-on-brand-primary)" }
- { name: "--plutonium-icon", family: brandIcon, lightness: 45 }
- { name: "--invite-verified-icon-color", value: "var(--brand-primary)" }
- { name: "--border-color", family: neutralLight, lightness: 40, alpha: 0.15 }
- { name: "--border-color-hover", family: neutralLight, lightness: 40, alpha: 0.25 }
- { name: "--border-color-focus", hue: 210, saturation: 90, useSaturationFactor: true, lightness: 50, alpha: 0.4 }
- { name: "--bg-primary", value: "var(--background-primary)" }
- { name: "--bg-secondary", value: "var(--background-secondary)" }
- { name: "--bg-tertiary", value: "var(--background-tertiary)" }
- { name: "--bg-hover", value: "var(--background-modifier-hover)" }
- { name: "--bg-active", value: "var(--background-modifier-selected)" }
- { name: "--bg-code", family: neutralLight, saturation: 22, lightness: 90, alpha: 0.9 }
- { name: "--bg-code-block", value: "var(--background-primary)" }
- { name: "--bg-blockquote", value: "var(--background-secondary-alt)" }
- { name: "--bg-table-header", value: "var(--background-tertiary)" }
- { name: "--bg-table-row-odd", value: "var(--background-primary)" }
- { name: "--bg-table-row-even", value: "var(--background-secondary)" }
- { name: "--alert-note-color", family: link, lightness: 45 }
- { name: "--alert-tip-color", hue: 150, saturation: 80, useSaturationFactor: true, lightness: 35 }
- { name: "--alert-important-color", family: accentPurple, lightness: 50 }
- { name: "--alert-warning-color", family: statusIdle, saturation: 90, lightness: 45 }
- { name: "--alert-caution-color", hue: 358, saturation: 80, useSaturationFactor: true, lightness: 50 }
- { name: "--spoiler-overlay-color", value: "rgba(0, 0, 0, 0.1)" }
- { name: "--spoiler-overlay-hover-color", value: "rgba(0, 0, 0, 0.15)" }
- { name: "--button-secondary-fill", family: neutralLight, saturation: 20, lightness: 10, alpha: 0.1 }
- { name: "--button-secondary-active-fill", family: neutralLight, saturation: 20, lightness: 10, alpha: 0.15 }
- { name: "--button-secondary-text", family: neutralLight, lightness: 15 }
- { name: "--button-secondary-active-text", family: neutralLight, lightness: 10 }
- { name: "--button-ghost-text", family: neutralLight, lightness: 20 }
- { name: "--button-inverted-fill", hue: 0, saturation: 0, lightness: 100 }
- { name: "--button-inverted-text", hue: 0, saturation: 0, lightness: 10 }
- { name: "--button-outline-border", value: "1px solid hsla(210, calc(20% * var(--saturation-factor)), 40%, 0.3)" }
- { name: "--button-outline-text", family: neutralLight, lightness: 20 }
- { name: "--button-outline-active-fill", family: neutralLight, saturation: 20, lightness: 10, alpha: 0.1 }
- { name: "--button-outline-active-border", value: "1px solid hsla(210, calc(20% * var(--saturation-factor)), 40%, 0.5)" }
- { name: "--button-danger-outline-border", value: "1px solid hsl(359, calc(70% * var(--saturation-factor)), 50%)" }
- { name: "--button-danger-outline-text", hue: 359, saturation: 70, useSaturationFactor: true, lightness: 45 }
- { name: "--button-danger-outline-active-fill", hue: 359, saturation: 70, useSaturationFactor: true, lightness: 50 }
- { name: "--user-area-divider-color", family: neutralLight, lightness: 40, alpha: 0.2 }
coal:
- scale: coalSurface
- { name: "--background-secondary", value: "var(--background-primary)" }
- { name: "--background-secondary-lighter", value: "var(--background-primary)" }
- name: "--panel-control-bg"
value: |
color-mix(
in srgb,
var(--background-primary) 90%,
hsl(220, calc(13% * var(--saturation-factor)), 0%) 10%
)
- { name: "--panel-control-border", family: neutralDark, saturation: 20, lightness: 30, alpha: 0.35 }
- { name: "--panel-control-divider", family: neutralDark, saturation: 20, lightness: 25, alpha: 0.28 }
- { name: "--panel-control-highlight", value: "hsla(0, 0%, 100%, 0.06)" }
- { name: "--background-modifier-hover", family: neutralDark, lightness: 100, alpha: 0.04 }
- { name: "--background-modifier-selected", family: neutralDark, lightness: 100, alpha: 0.08 }
- { name: "--background-modifier-accent", family: neutralDark, saturation: 10, lightness: 65, alpha: 0.18 }
- { name: "--background-modifier-accent-focus", family: neutralDark, saturation: 10, lightness: 70, alpha: 0.26 }
- { name: "--control-button-normal-bg", value: "transparent" }
- { name: "--control-button-normal-text", value: "var(--text-primary-muted)" }
- { name: "--control-button-hover-bg", family: neutralDark, lightness: 12 }
- { name: "--control-button-hover-text", value: "var(--text-primary)" }
- { name: "--control-button-active-bg", family: neutralDark, lightness: 14 }
- { name: "--control-button-active-text", value: "var(--text-primary)" }
- { name: "--scrollbar-thumb-bg", value: "rgba(160, 160, 160, 0.35)" }
- { name: "--scrollbar-thumb-bg-hover", value: "rgba(200, 200, 200, 0.55)" }
- { name: "--scrollbar-track-bg", value: "rgba(0, 0, 0, 0.45)" }
- { name: "--bg-primary", value: "var(--background-primary)" }
- { name: "--bg-secondary", value: "var(--background-secondary)" }
- { name: "--bg-tertiary", value: "var(--background-tertiary)" }
- { name: "--bg-hover", value: "var(--background-modifier-hover)" }
- { name: "--bg-active", value: "var(--background-modifier-selected)" }
- { name: "--bg-code", value: "hsl(220, calc(13% * var(--saturation-factor)), 8%)" }
- { name: "--bg-code-block", value: "var(--background-secondary-alt)" }
- { name: "--bg-blockquote", value: "var(--background-secondary)" }
- { name: "--bg-table-header", value: "var(--background-tertiary)" }
- { name: "--bg-table-row-odd", value: "var(--background-primary)" }
- { name: "--bg-table-row-even", value: "var(--background-secondary)" }
- { name: "--button-secondary-fill", value: "hsla(0, 0%, 100%, 0.04)" }
- { name: "--button-secondary-active-fill", value: "hsla(0, 0%, 100%, 0.07)" }
- { name: "--button-secondary-text", value: "var(--text-primary)" }
- { name: "--button-secondary-active-text", value: "var(--text-primary)" }
- { name: "--button-outline-border", value: "1px solid hsla(0, 0%, 100%, 0.08)" }
- { name: "--button-outline-active-fill", value: "hsla(0, 0%, 100%, 0.12)" }
- { name: "--button-outline-active-border", value: "1px solid hsla(0, 0%, 100%, 0.16)" }
- { name: "--user-area-divider-color", value: "color-mix(in srgb, var(--background-modifier-hover) 80%, transparent)" }

View File

@@ -0,0 +1,575 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
type AvatarSize = 16 | 20 | 24 | 32 | 36 | 40 | 48 | 56 | 80 | 120;
interface StatusConfig {
statusSize: number;
cutoutRadius: number;
cutoutCenter: number;
}
const STATUS_CONFIG: Record<number, StatusConfig> = {
16: {statusSize: 10, cutoutRadius: 5, cutoutCenter: 13},
20: {statusSize: 10, cutoutRadius: 5, cutoutCenter: 17},
24: {statusSize: 10, cutoutRadius: 7, cutoutCenter: 20},
32: {statusSize: 10, cutoutRadius: 8, cutoutCenter: 27},
36: {statusSize: 10, cutoutRadius: 8, cutoutCenter: 30},
40: {statusSize: 12, cutoutRadius: 9, cutoutCenter: 34},
48: {statusSize: 14, cutoutRadius: 10, cutoutCenter: 42},
56: {statusSize: 16, cutoutRadius: 11, cutoutCenter: 49},
80: {statusSize: 16, cutoutRadius: 14, cutoutCenter: 68},
120: {statusSize: 24, cutoutRadius: 20, cutoutCenter: 100},
};
const DESIGN_RULES = {
mobileAspectRatio: 0.75,
mobileCornerRadius: 0.12,
mobileScreenWidth: 0.72,
mobileScreenHeight: 0.7,
mobileScreenY: 0.06,
mobileWheelRadius: 0.13,
mobileWheelY: 0.83,
typingWidthMultiplier: 1.8,
mobilePhoneExtraHeight: 2,
mobileDisplayExtraHeight: 2,
mobileDisplayExtraWidthPerSide: 2,
idle: {
cutoutRadiusRatio: 0.7,
cutoutOffsetRatio: 0.35,
},
dnd: {
barWidthRatio: 1.3,
barHeightRatio: 0.4,
minBarHeight: 2,
},
offline: {
innerRingRatio: 0.6,
},
} as const;
const MOBILE_SCREEN_WIDTH_TRIM_PX = 4;
const MOBILE_SCREEN_HEIGHT_TRIM_PX = 2;
const MOBILE_SCREEN_X_OFFSET_PX = 0;
const MOBILE_SCREEN_Y_OFFSET_PX = 3;
function getStatusConfig(avatarSize: number): StatusConfig {
if (STATUS_CONFIG[avatarSize]) {
return STATUS_CONFIG[avatarSize];
}
const sizes = Object.keys(STATUS_CONFIG)
.map(Number)
.sort((a, b) => a - b);
const closest = sizes.reduce((prev, curr) =>
Math.abs(curr - avatarSize) < Math.abs(prev - avatarSize) ? curr : prev,
);
return STATUS_CONFIG[closest];
}
interface StatusGeometry {
size: number;
cx: number;
cy: number;
innerRadius: number;
outerRadius: number;
borderWidth: number;
}
interface MobileStatusGeometry extends StatusGeometry {
phoneWidth: number;
phoneHeight: number;
phoneX: number;
phoneY: number;
phoneRx: number;
bezelHeight: number;
}
function calculateStatusGeometry(avatarSize: number, isMobile: boolean = false): StatusGeometry | MobileStatusGeometry {
const config = getStatusConfig(avatarSize);
const statusSize = config.statusSize;
const cutoutCenter = config.cutoutCenter;
const cutoutRadius = config.cutoutRadius;
const innerRadius = statusSize / 2;
const outerRadius = cutoutRadius;
const borderWidth = cutoutRadius - innerRadius;
const baseGeometry = {
size: statusSize,
cx: cutoutCenter,
cy: cutoutCenter,
innerRadius,
outerRadius,
borderWidth,
};
if (!isMobile) {
return baseGeometry;
}
const phoneWidth = statusSize;
const phoneHeight = Math.round(phoneWidth / DESIGN_RULES.mobileAspectRatio) + DESIGN_RULES.mobilePhoneExtraHeight;
const phoneRx = Math.round(phoneWidth * DESIGN_RULES.mobileCornerRadius);
const bezelHeight = Math.max(1, Math.round(phoneHeight * 0.05));
const phoneX = cutoutCenter - phoneWidth / 2;
const phoneY = cutoutCenter - phoneHeight / 2;
return {
...baseGeometry,
phoneWidth,
phoneHeight,
phoneX,
phoneY,
phoneRx,
bezelHeight,
};
}
function generateAvatarMaskDefault(size: number): string {
const r = size / 2;
return `<circle fill="white" cx="${r}" cy="${r}" r="${r}" />`;
}
function generateAvatarMaskStatusRound(size: number): string {
const r = size / 2;
const status = calculateStatusGeometry(size);
return `(
<>
<circle fill="white" cx="${r}" cy="${r}" r="${r}" />
<circle fill="black" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />
</>
)`;
}
function generateAvatarMaskStatusTyping(size: number): string {
const r = size / 2;
const status = calculateStatusGeometry(size);
const typingWidth = Math.round(status.size * DESIGN_RULES.typingWidthMultiplier);
const typingHeight = status.size;
const typingRx = status.outerRadius;
const x = status.cx - typingWidth / 2;
const y = status.cy - typingHeight / 2;
return `(
<>
<circle fill="white" cx="${r}" cy="${r}" r="${r}" />
<rect fill="black" x="${x}" y="${y}" width="${typingWidth}" height="${typingHeight}" rx="${typingRx}" ry="${typingRx}" />
</>
)`;
}
function generateMobilePhoneMask(mobileStatus: MobileStatusGeometry): string {
const displayExtraHeight = DESIGN_RULES.mobileDisplayExtraHeight;
const displayExtraWidthPerSide = DESIGN_RULES.mobileDisplayExtraWidthPerSide;
const screenWidth =
mobileStatus.phoneWidth * DESIGN_RULES.mobileScreenWidth +
displayExtraWidthPerSide * 2 -
MOBILE_SCREEN_WIDTH_TRIM_PX;
const screenHeight =
mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenHeight + displayExtraHeight - MOBILE_SCREEN_HEIGHT_TRIM_PX;
const screenX = mobileStatus.phoneX + (mobileStatus.phoneWidth - screenWidth) / 2 + MOBILE_SCREEN_X_OFFSET_PX;
const screenY =
mobileStatus.phoneY +
mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenY -
displayExtraHeight / 2 +
MOBILE_SCREEN_Y_OFFSET_PX;
const screenRx = Math.min(screenWidth, screenHeight) * 0.1;
const wheelRadius = mobileStatus.phoneWidth * DESIGN_RULES.mobileWheelRadius;
const wheelCx = mobileStatus.phoneX + mobileStatus.phoneWidth / 2;
const wheelCy = mobileStatus.phoneY + mobileStatus.phoneHeight * DESIGN_RULES.mobileWheelY;
return `(
<>
<rect fill="white" x="${mobileStatus.phoneX}" y="${mobileStatus.phoneY}" width="${mobileStatus.phoneWidth}" height="${mobileStatus.phoneHeight}" rx="${mobileStatus.phoneRx}" ry="${mobileStatus.phoneRx}" />
<rect fill="black" x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}" rx="${screenRx}" ry="${screenRx}" />
<circle fill="black" cx="${wheelCx}" cy="${wheelCy}" r="${wheelRadius}" />
</>
)`;
}
function generateStatusOnline(size: number, isMobile: boolean = false): string {
const status = calculateStatusGeometry(size, isMobile);
if (!isMobile) {
return `<circle fill="white" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />`;
}
return generateMobilePhoneMask(status as MobileStatusGeometry);
}
function generateStatusIdle(size: number, isMobile: boolean = false): string {
const status = calculateStatusGeometry(size, isMobile);
if (!isMobile) {
const cutoutRadius = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutRadiusRatio);
const cutoutOffsetDistance = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutOffsetRatio);
const cutoutCx = status.cx - cutoutOffsetDistance;
const cutoutCy = status.cy - cutoutOffsetDistance;
return `(
<>
<circle fill="white" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />
<circle fill="black" cx="${cutoutCx}" cy="${cutoutCy}" r="${cutoutRadius}" />
</>
)`;
}
return generateMobilePhoneMask(status as MobileStatusGeometry);
}
function generateStatusDnd(size: number, isMobile: boolean = false): string {
const status = calculateStatusGeometry(size, isMobile);
if (!isMobile) {
const barWidth = Math.round(status.outerRadius * DESIGN_RULES.dnd.barWidthRatio);
const rawBarHeight = status.outerRadius * DESIGN_RULES.dnd.barHeightRatio;
const barHeight = Math.max(DESIGN_RULES.dnd.minBarHeight, Math.round(rawBarHeight));
const barX = status.cx - barWidth / 2;
const barY = status.cy - barHeight / 2;
const barRx = barHeight / 2;
return `(
<>
<circle fill="white" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />
<rect fill="black" x="${barX}" y="${barY}" width="${barWidth}" height="${barHeight}" rx="${barRx}" ry="${barRx}" />
</>
)`;
}
return generateMobilePhoneMask(status as MobileStatusGeometry);
}
function generateStatusOffline(size: number): string {
const status = calculateStatusGeometry(size);
const innerRadius = Math.round(status.innerRadius * DESIGN_RULES.offline.innerRingRatio);
return `(
<>
<circle fill="white" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />
<circle fill="black" cx="${status.cx}" cy="${status.cy}" r="${innerRadius}" />
</>
)`;
}
function generateStatusTyping(size: number): string {
const status = calculateStatusGeometry(size);
const typingWidth = Math.round(status.size * DESIGN_RULES.typingWidthMultiplier);
const typingHeight = status.size;
const rx = status.outerRadius;
const x = status.cx - typingWidth / 2;
const y = status.cy - typingHeight / 2;
return `<rect fill="white" x="${x}" y="${y}" width="${typingWidth}" height="${typingHeight}" rx="${rx}" ry="${rx}" />`;
}
const SIZES: Array<AvatarSize> = [16, 20, 24, 32, 36, 40, 48, 56, 80, 120];
let output = `// @generated - DO NOT EDIT MANUALLY
// Run: pnpm generate:masks
type AvatarSize = ${SIZES.join(' | ')};
interface MaskDefinition {
viewBox: string;
content: React.ReactElement;
}
interface MaskSet {
avatarDefault: MaskDefinition;
avatarStatusRound: MaskDefinition;
avatarStatusTyping: MaskDefinition;
statusOnline: MaskDefinition;
statusOnlineMobile: MaskDefinition;
statusIdle: MaskDefinition;
statusIdleMobile: MaskDefinition;
statusDnd: MaskDefinition;
statusDndMobile: MaskDefinition;
statusOffline: MaskDefinition;
statusTyping: MaskDefinition;
}
export const AVATAR_MASKS: Record<AvatarSize, MaskSet> = {
`;
for (const size of SIZES) {
output += ` ${size}: {
avatarDefault: {
viewBox: '0 0 ${size} ${size}',
content: ${generateAvatarMaskDefault(size)},
},
avatarStatusRound: {
viewBox: '0 0 ${size} ${size}',
content: ${generateAvatarMaskStatusRound(size)},
},
avatarStatusTyping: {
viewBox: '0 0 ${size} ${size}',
content: ${generateAvatarMaskStatusTyping(size)},
},
statusOnline: {
viewBox: '0 0 ${size} ${size}',
content: ${generateStatusOnline(size, false)},
},
statusOnlineMobile: {
viewBox: '0 0 ${size} ${size}',
content: ${generateStatusOnline(size, true)},
},
statusIdle: {
viewBox: '0 0 ${size} ${size}',
content: ${generateStatusIdle(size, false)},
},
statusIdleMobile: {
viewBox: '0 0 ${size} ${size}',
content: ${generateStatusIdle(size, true)},
},
statusDnd: {
viewBox: '0 0 ${size} ${size}',
content: ${generateStatusDnd(size, false)},
},
statusDndMobile: {
viewBox: '0 0 ${size} ${size}',
content: ${generateStatusDnd(size, true)},
},
statusOffline: {
viewBox: '0 0 ${size} ${size}',
content: ${generateStatusOffline(size)},
},
statusTyping: {
viewBox: '0 0 ${size} ${size}',
content: ${generateStatusTyping(size)},
},
},
`;
}
output += `} as const;
export const SVGMasks = () => (
<svg
viewBox="0 0 1 1"
aria-hidden={true}
style={{
position: 'absolute',
pointerEvents: 'none',
top: '-1px',
left: '-1px',
width: 1,
height: 1,
}}
>
<defs>
`;
for (const size of SIZES) {
const status = calculateStatusGeometry(size, false);
const mobileStatus = calculateStatusGeometry(size, true) as MobileStatusGeometry;
const cx = status.cx / size;
const cy = status.cy / size;
const r = status.outerRadius / size;
const idleCutoutR = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutRadiusRatio) / size;
const idleCutoutOffset = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutOffsetRatio) / size;
const idleCutoutCx = cx - idleCutoutOffset;
const idleCutoutCy = cy - idleCutoutOffset;
const dndBarWidth = Math.round(status.outerRadius * DESIGN_RULES.dnd.barWidthRatio) / size;
const dndBarHeight =
Math.max(DESIGN_RULES.dnd.minBarHeight, Math.round(status.outerRadius * DESIGN_RULES.dnd.barHeightRatio)) / size;
const dndBarX = cx - dndBarWidth / 2;
const dndBarY = cy - dndBarHeight / 2;
const dndBarRx = dndBarHeight / 2;
const offlineInnerR = Math.round(status.innerRadius * DESIGN_RULES.offline.innerRingRatio) / size;
const typingWidth = Math.round(status.size * DESIGN_RULES.typingWidthMultiplier) / size;
const typingHeight = status.size / size;
const typingX = cx - typingWidth / 2;
const typingY = cy - typingHeight / 2;
const typingRx = status.outerRadius / size;
const cutoutPhoneWidth = (mobileStatus.phoneWidth + mobileStatus.borderWidth * 2) / size;
const cutoutPhoneHeight = (mobileStatus.phoneHeight + mobileStatus.borderWidth * 2) / size;
const cutoutPhoneX = (mobileStatus.phoneX - mobileStatus.borderWidth) / size;
const cutoutPhoneY = (mobileStatus.phoneY - mobileStatus.borderWidth) / size;
const cutoutPhoneRx = (mobileStatus.phoneRx + mobileStatus.borderWidth) / size;
const displayExtraHeight = DESIGN_RULES.mobileDisplayExtraHeight;
const displayExtraWidthPerSide = DESIGN_RULES.mobileDisplayExtraWidthPerSide;
const screenWidthPx =
mobileStatus.phoneWidth * DESIGN_RULES.mobileScreenWidth +
displayExtraWidthPerSide * 2 -
MOBILE_SCREEN_WIDTH_TRIM_PX;
const screenHeightPx =
mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenHeight + displayExtraHeight - MOBILE_SCREEN_HEIGHT_TRIM_PX;
const screenXpx = mobileStatus.phoneX + (mobileStatus.phoneWidth - screenWidthPx) / 2 + MOBILE_SCREEN_X_OFFSET_PX;
const screenYpx =
mobileStatus.phoneY +
mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenY -
displayExtraHeight / 2 +
MOBILE_SCREEN_Y_OFFSET_PX;
const screenRxPx = Math.min(screenWidthPx, screenHeightPx) * 0.1;
const mobileScreenX = ((screenXpx - mobileStatus.phoneX) / mobileStatus.phoneWidth).toFixed(4);
const mobileScreenY = ((screenYpx - mobileStatus.phoneY) / mobileStatus.phoneHeight).toFixed(4);
const mobileScreenWidth = (screenWidthPx / mobileStatus.phoneWidth).toFixed(4);
const mobileScreenHeight = ((screenHeightPx / mobileStatus.phoneHeight) * DESIGN_RULES.mobileAspectRatio).toFixed(4);
const mobileScreenRx = (screenRxPx / mobileStatus.phoneWidth).toFixed(4);
const mobileScreenRy = ((screenRxPx / mobileStatus.phoneWidth) * DESIGN_RULES.mobileAspectRatio).toFixed(4);
output += ` <mask id="svg-mask-avatar-default-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
</mask>
<mask id="svg-mask-avatar-status-round-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
<circle fill="black" cx="${cx}" cy="${cy}" r="${r}" />
</mask>
<mask id="svg-mask-avatar-status-mobile-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
<rect fill="black" x="${cutoutPhoneX}" y="${cutoutPhoneY}" width="${cutoutPhoneWidth}" height="${cutoutPhoneHeight}" rx="${cutoutPhoneRx}" ry="${cutoutPhoneRx}" />
</mask>
<mask id="svg-mask-avatar-status-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
<rect fill="black" x="${typingX}" y="${typingY}" width="${typingWidth}" height="${typingHeight}" rx="${typingRx}" ry="${typingRx}" />
</mask>
<mask id="svg-mask-status-online-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="${cx}" cy="${cy}" r="${r}" />
</mask>
<mask id="svg-mask-status-online-mobile-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<rect fill="white" x="0" y="0" width="1" height="1" rx="${DESIGN_RULES.mobileCornerRadius}" ry="${(DESIGN_RULES.mobileCornerRadius * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" />
<rect fill="black" x="${mobileScreenX}" y="${mobileScreenY}" width="${mobileScreenWidth}" height="${mobileScreenHeight}" rx="${mobileScreenRx}" ry="${mobileScreenRy}" />
<ellipse fill="black" cx="0.5" cy="${DESIGN_RULES.mobileWheelY}" rx="${DESIGN_RULES.mobileWheelRadius}" ry="${(DESIGN_RULES.mobileWheelRadius * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" />
</mask>
<mask id="svg-mask-status-idle-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="${cx}" cy="${cy}" r="${r}" />
<circle fill="black" cx="${idleCutoutCx}" cy="${idleCutoutCy}" r="${idleCutoutR}" />
</mask>
<mask id="svg-mask-status-dnd-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="${cx}" cy="${cy}" r="${r}" />
<rect fill="black" x="${dndBarX}" y="${dndBarY}" width="${dndBarWidth}" height="${dndBarHeight}" rx="${dndBarRx}" ry="${dndBarRx}" />
</mask>
<mask id="svg-mask-status-offline-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="${cx}" cy="${cy}" r="${r}" />
<circle fill="black" cx="${cx}" cy="${cy}" r="${offlineInnerR}" />
</mask>
<mask id="svg-mask-status-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<rect fill="white" x="${typingX}" y="${typingY}" width="${typingWidth}" height="${typingHeight}" rx="${typingRx}" ry="${typingRx}" />
</mask>
`;
}
output += ` <mask id="svg-mask-status-online" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
</mask>
<mask id="svg-mask-status-idle" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
<circle fill="black" cx="0.25" cy="0.25" r="0.375" />
</mask>
<mask id="svg-mask-status-dnd" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
<rect fill="black" x="0.125" y="0.375" width="0.75" height="0.25" rx="0.125" ry="0.125" />
</mask>
<mask id="svg-mask-status-offline" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
<circle fill="black" cx="0.5" cy="0.5" r="0.25" />
</mask>
<mask id="svg-mask-status-typing" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<rect fill="white" x="0" y="0" width="1" height="1" rx="0.5" ry="0.5" />
</mask>
<mask id="svg-mask-status-online-mobile" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<rect fill="white" x="0" y="0" width="1" height="1" rx="${DESIGN_RULES.mobileCornerRadius}" ry="${(DESIGN_RULES.mobileCornerRadius * DESIGN_RULES.mobileAspectRatio).toFixed(2)}" />
<rect fill="black" x="${((1 - DESIGN_RULES.mobileScreenWidth) / 2).toFixed(4)}" y="${DESIGN_RULES.mobileScreenY}" width="${DESIGN_RULES.mobileScreenWidth}" height="${(DESIGN_RULES.mobileScreenHeight * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" rx="0.04" ry="${(0.04 * DESIGN_RULES.mobileAspectRatio).toFixed(2)}" />
<ellipse fill="black" cx="0.5" cy="${DESIGN_RULES.mobileWheelY}" rx="${DESIGN_RULES.mobileWheelRadius}" ry="${(DESIGN_RULES.mobileWheelRadius * DESIGN_RULES.mobileAspectRatio).toFixed(3)}" />
</mask>
<mask id="svg-mask-avatar-default" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
</mask>
</defs>
</svg>
);
`;
const outputPath = path.join(__dirname, '../src/components/uikit/SVGMasks.tsx');
fs.writeFileSync(outputPath, output);
console.log(`Generated ${outputPath}`);
const layoutOutput = `// @generated - DO NOT EDIT MANUALLY
// Run: pnpm generate:masks
export interface StatusGeometry {
size: number;
cx: number;
cy: number;
radius: number;
borderWidth: number;
isMobile?: boolean;
phoneWidth?: number;
phoneHeight?: number;
}
const STATUS_GEOMETRY: Record<number, StatusGeometry> = {
${SIZES.map((size) => {
const geom = calculateStatusGeometry(size, false);
return ` ${size}: {size: ${geom.size}, cx: ${geom.cx}, cy: ${geom.cy}, radius: ${geom.outerRadius}, borderWidth: ${geom.borderWidth}, isMobile: false}`;
}).join(',\n')},
};
const STATUS_GEOMETRY_MOBILE: Record<number, StatusGeometry> = {
${SIZES.map((size) => {
const geom = calculateStatusGeometry(size, true) as MobileStatusGeometry;
return ` ${size}: {size: ${geom.size}, cx: ${geom.cx}, cy: ${geom.cy}, radius: ${geom.outerRadius}, borderWidth: ${geom.borderWidth}, isMobile: true, phoneWidth: ${geom.phoneWidth}, phoneHeight: ${geom.phoneHeight}}`;
}).join(',\n')},
};
export function getStatusGeometry(avatarSize: number, isMobile: boolean = false): StatusGeometry {
const map = isMobile ? STATUS_GEOMETRY_MOBILE : STATUS_GEOMETRY;
if (map[avatarSize]) {
return map[avatarSize];
}
const closestSize = Object.keys(map)
.map(Number)
.reduce((prev, curr) => (Math.abs(curr - avatarSize) < Math.abs(prev - avatarSize) ? curr : prev));
return map[closestSize];
}
`;
const layoutPath = path.join(__dirname, '../src/components/uikit/AvatarStatusGeometry.ts');
fs.writeFileSync(layoutPath, layoutOutput);

42
fluxer_app/scripts/go.mod Normal file
View File

@@ -0,0 +1,42 @@
module fluxer_app/scripts
go 1.25.5
require (
github.com/aws/aws-sdk-go-v2 v1.41.0
github.com/aws/aws-sdk-go-v2/config v1.32.5
github.com/aws/aws-sdk-go-v2/credentials v1.19.5
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.15
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2
github.com/google/uuid v1.6.0
github.com/schollz/progressbar/v3 v3.18.0
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
golang.org/x/sync v0.19.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect
golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.28.0 // indirect
golang.org/x/text v0.3.6 // indirect
)

79
fluxer_app/scripts/go.sum Normal file
View File

@@ -0,0 +1,79 @@
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/config v1.32.5 h1:pz3duhAfUgnxbtVhIK39PGF/AHYyrzGEyRD9Og0QrE8=
github.com/aws/aws-sdk-go-v2/config v1.32.5/go.mod h1:xmDjzSUs/d0BB7ClzYPAZMmgQdrodNjPPhd6bGASwoE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.5 h1:xMo63RlqP3ZZydpJDMBsH9uJ10hgHYfQFIk1cHDXrR4=
github.com/aws/aws-sdk-go-v2/credentials v1.19.5/go.mod h1:hhbH6oRcou+LpXfA/0vPElh/e0M3aFeOblE1sssAAEk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.15 h1:Zn4SfxkULorRqLg/VhxQ5cg9bi8Qhq7Y8W9RUew15oI=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.15/go.mod h1:uFphWOp8hzgUQ6ORHAw2WUf2xeqOWHjhgCDSdAVxzp0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2 h1:U3ygWUhCpiSPYSHOrRhb3gOl9T5Y3kB8k5Vjs//57bE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ=
golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 h1:DZshvxDdVoeKIbudAdFEKi+f70l51luSy/7b76ibTY0=
golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,47 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {writeFile} from 'node:fs/promises';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const allowedChannels = new Set(['stable', 'canary']);
const rawChannel = process.env.BUILD_CHANNEL?.toLowerCase() ?? '';
const channel = allowedChannels.has(rawChannel) ? rawChannel : 'stable';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const targetPath = path.resolve(scriptDir, '..', 'src-electron', 'common', 'build-channel.ts');
const fileContent = `/*
* This file is generated by scripts/set-build-channel.mjs.
* DO NOT EDIT MANUALLY.
*/
export type BuildChannel = 'stable' | 'canary';
const DEFAULT_BUILD_CHANNEL = '${channel}' as BuildChannel;
const envChannel = process.env.BUILD_CHANNEL?.toLowerCase();
export const BUILD_CHANNEL = (envChannel === 'canary' ? 'canary' : DEFAULT_BUILD_CHANNEL) as BuildChannel;
export const IS_CANARY = BUILD_CHANNEL === 'canary';
export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;
`;
await writeFile(targetPath, fileContent, 'utf8');
console.log(`Wrote ${targetPath} with channel '${channel}'`);