feat: Add new emoji assets and an UpdateBanner component.
Some checks failed
Build and Release / build-and-release (push) Failing after 3m28s
Some checks failed
Build and Release / build-and-release (push) Failing after 3m28s
This commit is contained in:
10
packages/platform-web/package.json
Normal file
10
packages/platform-web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
223
packages/platform-web/src/crypto.js
Normal file
223
packages/platform-web/src/crypto.js
Normal 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,
|
||||
};
|
||||
36
packages/platform-web/src/idle.js
Normal file
36
packages/platform-web/src/idle.js
Normal 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);
|
||||
},
|
||||
};
|
||||
41
packages/platform-web/src/index.js
Normal file
41
packages/platform-web/src/index.js
Normal 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;
|
||||
34
packages/platform-web/src/session.js
Normal file
34
packages/platform-web/src/session.js
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
25
packages/platform-web/src/settings.js
Normal file
25
packages/platform-web/src/settings.js
Normal 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();
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user