feat: Add new emoji assets and an UpdateBanner component.
Some checks failed
Build and Release / build-and-release (push) Failing after 3m28s

This commit is contained in:
Bryan1029384756
2026-02-13 12:20:40 -06:00
parent 63d4208933
commit fe869a3222
3855 changed files with 10226 additions and 15543 deletions

View File

@@ -0,0 +1,10 @@
{
"name": "@discord-clone/platform-web",
"private": true,
"version": "1.0.0",
"type": "module",
"main": "src/index.js",
"dependencies": {
"scrypt-js": "^3.0.1"
}
}

View File

@@ -0,0 +1,223 @@
/**
* Web Crypto API implementation of the platform crypto interface.
* Compatible with the Electron (Node.js crypto) implementation:
* - RSA-OAEP 2048 with SHA-256 for public key encryption
* - Ed25519 for message signing/verification
* - AES-256-GCM for symmetric encryption
* - scrypt for password-based key derivation (via scrypt-js)
* - SHA-256 for hashing
*
* Keys are exchanged as PEM strings (SPKI public, PKCS8 private)
* to maintain interoperability with the Electron app.
*/
import { scrypt as scryptAsync } from 'scrypt-js';
const encoder = new TextEncoder();
const decoder = new TextDecoder();
// --- Hex / ArrayBuffer helpers ---
function bufToHex(buf) {
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
}
function hexToBuf(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes.buffer;
}
// --- PEM encode / decode ---
function pemEncode(der, type) {
const b64 = btoa(String.fromCharCode(...new Uint8Array(der)));
const lines = b64.match(/.{1,64}/g) || [];
return `-----BEGIN ${type}-----\n${lines.join('\n')}\n-----END ${type}-----`;
}
function pemDecode(pem) {
const b64 = pem.replace(/-----[A-Z ]+-----/g, '').replace(/\s/g, '');
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
// --- Key Generation ---
async function generateKeys() {
// Generate RSA-OAEP 2048 key pair
const rsaKeyPair = await crypto.subtle.generateKey(
{ name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
true,
['encrypt', 'decrypt']
);
const rsaPubDer = await crypto.subtle.exportKey('spki', rsaKeyPair.publicKey);
const rsaPrivDer = await crypto.subtle.exportKey('pkcs8', rsaKeyPair.privateKey);
// Generate Ed25519 key pair
const edKeyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']);
const edPubDer = await crypto.subtle.exportKey('spki', edKeyPair.publicKey);
const edPrivDer = await crypto.subtle.exportKey('pkcs8', edKeyPair.privateKey);
return {
rsaPub: pemEncode(rsaPubDer, 'PUBLIC KEY'),
rsaPriv: pemEncode(rsaPrivDer, 'PRIVATE KEY'),
edPub: pemEncode(edPubDer, 'PUBLIC KEY'),
edPriv: pemEncode(edPrivDer, 'PRIVATE KEY'),
};
}
// --- Random bytes (returns hex string) ---
function randomBytes(size) {
const bytes = new Uint8Array(size);
crypto.getRandomValues(bytes);
return bufToHex(bytes);
}
// --- SHA-256 (returns hex string) ---
async function sha256(data) {
const digest = await crypto.subtle.digest('SHA-256', encoder.encode(data));
return bufToHex(digest);
}
// --- Ed25519 Sign / Verify ---
async function signMessage(privateKeyPem, message) {
const keyData = pemDecode(privateKeyPem);
const key = await crypto.subtle.importKey('pkcs8', keyData, 'Ed25519', false, ['sign']);
const sig = await crypto.subtle.sign('Ed25519', key, encoder.encode(message));
return bufToHex(sig);
}
async function verifySignature(publicKeyPem, message, signatureHex) {
const keyData = pemDecode(publicKeyPem);
const key = await crypto.subtle.importKey('spki', keyData, 'Ed25519', false, ['verify']);
const sigBuf = hexToBuf(signatureHex);
return crypto.subtle.verify('Ed25519', key, sigBuf, encoder.encode(message));
}
// --- scrypt Key Derivation ---
async function deriveAuthKeys(password, salt) {
const passwordBuf = encoder.encode(password);
const saltBuf = encoder.encode(salt);
// Match Node.js scrypt params: keylen=64, N=16384, r=8, p=1 (Node defaults)
const N = 16384, r = 8, p = 1, dkLen = 64;
const derivedKey = await scryptAsync(passwordBuf, saltBuf, N, r, p, dkLen);
const dak = bufToHex(derivedKey.slice(0, 32));
const dek = derivedKey.slice(32, 64);
return { dak, dek };
}
// --- AES-256-GCM Encrypt ---
async function encryptData(plaintext, keyInput) {
const keyBuf = typeof keyInput === 'string' ? hexToBuf(keyInput) : keyInput;
const key = await crypto.subtle.importKey('raw', keyBuf, 'AES-GCM', false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV
// Encode plaintext as UTF-8 if string
const dataBuf = typeof plaintext === 'string' ? encoder.encode(plaintext) : new Uint8Array(plaintext);
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, tagLength: 128 }, key, dataBuf);
// Web Crypto appends the auth tag (16 bytes) to the ciphertext
const encBytes = new Uint8Array(encrypted);
const ciphertext = encBytes.slice(0, encBytes.length - 16);
const tag = encBytes.slice(encBytes.length - 16);
return {
content: bufToHex(ciphertext),
iv: bufToHex(iv),
tag: bufToHex(tag),
};
}
// --- AES-256-GCM Decrypt ---
async function decryptData(ciphertextHex, keyInput, ivHex, tagHex, options = {}) {
const keyBuf = typeof keyInput === 'string' ? hexToBuf(keyInput) : keyInput;
const key = await crypto.subtle.importKey('raw', keyBuf, 'AES-GCM', false, ['decrypt']);
const iv = new Uint8Array(hexToBuf(ivHex));
// Web Crypto expects ciphertext + tag concatenated
const cipherBytes = new Uint8Array(hexToBuf(ciphertextHex));
const tagBytes = new Uint8Array(hexToBuf(tagHex));
const combined = new Uint8Array(cipherBytes.length + tagBytes.length);
combined.set(cipherBytes, 0);
combined.set(tagBytes, cipherBytes.length);
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv, tagLength: 128 }, key, combined);
if (options.encoding === 'buffer') {
return decrypted;
}
return decoder.decode(decrypted);
}
// --- Batch Decrypt ---
async function decryptBatch(items) {
return Promise.all(items.map(async ({ ciphertext, key, iv, tag }) => {
try {
const data = await decryptData(ciphertext, key, iv, tag);
return { success: true, data };
} catch {
return { success: false, data: null };
}
}));
}
// --- Batch Verify ---
async function verifyBatch(items) {
return Promise.all(items.map(async ({ publicKey, message, signature }) => {
try {
const verified = await verifySignature(publicKey, message, signature);
return { success: true, verified };
} catch {
return { success: false, verified: false };
}
}));
}
// --- RSA-OAEP Public Encrypt ---
async function publicEncrypt(publicKeyPem, data) {
const keyData = pemDecode(publicKeyPem);
const key = await crypto.subtle.importKey('spki', keyData, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt']);
const dataBuf = typeof data === 'string' ? encoder.encode(data) : new Uint8Array(data);
const encrypted = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, key, dataBuf);
return bufToHex(encrypted);
}
// --- RSA-OAEP Private Decrypt ---
async function privateDecrypt(privateKeyPem, encryptedHex) {
const keyData = pemDecode(privateKeyPem);
const key = await crypto.subtle.importKey('pkcs8', keyData, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt']);
const encBuf = hexToBuf(encryptedHex);
const decrypted = await crypto.subtle.decrypt({ name: 'RSA-OAEP' }, key, encBuf);
return decoder.decode(decrypted);
}
export default {
generateKeys,
randomBytes,
sha256,
signMessage,
verifySignature,
deriveAuthKeys,
encryptData,
decryptData,
decryptBatch,
verifyBatch,
publicEncrypt,
privateDecrypt,
};

View File

@@ -0,0 +1,36 @@
/**
* Web platform idle detection using Page Visibility API.
* Provides a simplified version of the Electron idle API.
*/
let idleCallback = null;
let lastActiveTime = Date.now();
function handleVisibilityChange() {
if (!idleCallback) return;
if (document.hidden) {
idleCallback({ isIdle: true });
} else {
lastActiveTime = Date.now();
idleCallback({ isIdle: false });
}
}
export default {
getSystemIdleTime() {
// Return seconds since last activity (approximation using visibility)
if (document.hidden) {
return Math.floor((Date.now() - lastActiveTime) / 1000);
}
return 0;
},
onIdleStateChanged(callback) {
idleCallback = callback;
document.addEventListener('visibilitychange', handleVisibilityChange);
},
removeIdleStateListener() {
idleCallback = null;
document.removeEventListener('visibilitychange', handleVisibilityChange);
},
};

View File

@@ -0,0 +1,41 @@
/**
* Web/Capacitor platform implementation.
* Uses Web Crypto API, localStorage, and Page Visibility API.
*/
import crypto from './crypto.js';
import session from './session.js';
import settings from './settings.js';
import idle from './idle.js';
const webPlatform = {
crypto,
session,
settings,
idle,
links: {
openExternal(url) {
window.open(url, '_blank', 'noopener,noreferrer');
},
async fetchMetadata(url) {
// On web, metadata fetching would hit CORS. Use a Convex action or proxy instead.
// Return null to gracefully skip link previews that require server-side fetching.
return null;
},
},
screenCapture: {
async getScreenSources() {
// Web uses getDisplayMedia directly (no source picker like Electron).
// Return empty array; the web UI should call navigator.mediaDevices.getDisplayMedia() directly.
return [];
},
},
windowControls: null,
updates: null,
features: {
hasWindowControls: false,
hasScreenCapture: true,
hasNativeUpdates: false,
},
};
export default webPlatform;

View File

@@ -0,0 +1,34 @@
/**
* Web platform session persistence using localStorage.
* Returns Promises to match the Electron IPC-based API contract.
*/
const SESSION_KEY = 'discord-clone-session';
export default {
save(data) {
try {
localStorage.setItem(SESSION_KEY, JSON.stringify(data));
return Promise.resolve(true);
} catch {
return Promise.resolve(false);
}
},
load() {
try {
const raw = localStorage.getItem(SESSION_KEY);
return Promise.resolve(raw ? JSON.parse(raw) : null);
} catch {
return Promise.resolve(null);
}
},
clear() {
try {
localStorage.removeItem(SESSION_KEY);
return Promise.resolve(true);
} catch {
return Promise.resolve(false);
}
},
};

View File

@@ -0,0 +1,25 @@
/**
* Web platform settings using localStorage.
* Returns Promises to match the Electron IPC-based API contract.
*/
const PREFIX = 'discord-clone-settings:';
export default {
get(key) {
try {
const raw = localStorage.getItem(PREFIX + key);
return Promise.resolve(raw !== null ? JSON.parse(raw) : undefined);
} catch {
return Promise.resolve(undefined);
}
},
set(key, value) {
try {
localStorage.setItem(PREFIX + key, JSON.stringify(value));
return Promise.resolve();
} catch {
return Promise.resolve();
}
},
};