6 Commits

Author SHA1 Message Date
Myx
00adf39121 Add translation support for website
All checks were successful
Queue Release Build / prepare (push) Successful in 1m20s
Deploy Web Apps / deploy (push) Successful in 17m37s
Queue Release Build / build-windows (push) Successful in 39m8s
Queue Release Build / build-linux (push) Successful in 1h3m53s
Queue Release Build / finalize (push) Successful in 5m43s
2026-03-13 03:45:29 +01:00
Myx
2b6e477c9a [Attempt 1] fix slow website
Some checks failed
Queue Release Build / prepare (push) Successful in 25s
Deploy Web Apps / deploy (push) Successful in 17m20s
Queue Release Build / finalize (push) Has been cancelled
Queue Release Build / build-linux (push) Has started running
Queue Release Build / build-windows (push) Has been cancelled
2026-03-13 02:46:21 +01:00
Myx
22d355a522 [Experimental Screenshare audio fix] Seperate logic to own files (minor change can possibly revert) 2026-03-13 02:26:55 +01:00
Myx
15c5952e29 Improved logger 2026-03-13 00:48:33 +01:00
Myx
781c05294f feat!: [Experimental hotfix 2] Fix Audio stream handling on windows electron
Experimental fix for solving replacement of mic audio when enabling screenshare audio on windows electron

On Windows Electron, startWithElectronDesktopCapturer uses getUserMedia({ chromeMediaSource: 'desktop' }) for both video and audio. This getUserMedia desktop audio call can interfere with / replace the existing mic getUserMedia stream, killing voice audio.

BREAKING CHANGE: possibly streaming
2026-03-12 23:55:38 +01:00
Myx
778e75bef5 fix: [Experimental hotfix 1] Fix Signaling issues Toju App
1. Server: WebSocket ping/pong heartbeat (index.ts)
Added a 30-second ping interval that pings all connected clients
Connections without a pong response for 45 seconds are terminated and cleaned up
Extracted removeDeadConnection() to deduplicate the cleanup logic between close events and dead connection reaping
2. Server: Fixed sendServerUsers filter bug (handler.ts:13)
Removed && cu.displayName from the filter — users who joined a server before their identify message was processed were silently invisible to everyone. This was the direct cause of "can't see each other" in session 2.
3. Client: Typing message now includes serverId
Added serverId: this.webrtc.currentServerId to the typing payload
Added a currentServerId getter on WebRTCService
2026-03-12 23:53:10 +01:00
52 changed files with 3351 additions and 1270 deletions

View File

@@ -32,6 +32,11 @@ interface SinkInputDetails extends ShortSinkInputEntry {
properties: Record<string, string>; properties: Record<string, string>;
} }
interface DescendantProcessInfo {
ids: ReadonlySet<string>;
binaryNames: ReadonlySet<string>;
}
interface PactlJsonSinkInputEntry { interface PactlJsonSinkInputEntry {
index?: number | string; index?: number | string;
properties?: Record<string, unknown>; properties?: Record<string, unknown>;
@@ -44,6 +49,7 @@ interface LinuxScreenShareAudioRoutingState {
screenShareLoopbackModuleId: string | null; screenShareLoopbackModuleId: string | null;
voiceLoopbackModuleId: string | null; voiceLoopbackModuleId: string | null;
rerouteIntervalId: ReturnType<typeof setInterval> | null; rerouteIntervalId: ReturnType<typeof setInterval> | null;
subscribeProcess: ChildProcess | null;
} }
interface LinuxScreenShareMonitorCaptureState { interface LinuxScreenShareMonitorCaptureState {
@@ -77,7 +83,8 @@ const routingState: LinuxScreenShareAudioRoutingState = {
restoreSinkName: null, restoreSinkName: null,
screenShareLoopbackModuleId: null, screenShareLoopbackModuleId: null,
voiceLoopbackModuleId: null, voiceLoopbackModuleId: null,
rerouteIntervalId: null rerouteIntervalId: null,
subscribeProcess: null
}; };
const monitorCaptureState: LinuxScreenShareMonitorCaptureState = { const monitorCaptureState: LinuxScreenShareMonitorCaptureState = {
captureId: null, captureId: null,
@@ -126,12 +133,21 @@ export async function activateLinuxScreenShareAudioRouting(): Promise<LinuxScree
routingState.screenShareLoopbackModuleId = await loadLoopbackModule(SCREEN_SHARE_MONITOR_SOURCE_NAME, restoreSinkName); routingState.screenShareLoopbackModuleId = await loadLoopbackModule(SCREEN_SHARE_MONITOR_SOURCE_NAME, restoreSinkName);
routingState.voiceLoopbackModuleId = await loadLoopbackModule(`${VOICE_SINK_NAME}.monitor`, restoreSinkName); routingState.voiceLoopbackModuleId = await loadLoopbackModule(`${VOICE_SINK_NAME}.monitor`, restoreSinkName);
await setDefaultSink(SCREEN_SHARE_SINK_NAME); // Set the default sink to the voice sink so that new app audio
await moveSinkInputs(SCREEN_SHARE_SINK_NAME, (sinkName) => !!sinkName && sinkName !== SCREEN_SHARE_SINK_NAME && sinkName !== VOICE_SINK_NAME); // streams (received WebRTC voice) never land on the screenshare
// capture sink. This prevents the feedback loop where remote
// voice audio was picked up by parec before the reroute interval
// could move the stream away.
await setDefaultSink(VOICE_SINK_NAME);
routingState.active = true; routingState.active = true;
await rerouteAppSinkInputsToVoiceSink();
// Let the combined reroute decide placement for every existing
// stream. This avoids briefly shoving the app's own playback to the
// screenshare sink before ownership detection can move it back.
await rerouteSinkInputs();
startSinkInputRerouteLoop(); startSinkInputRerouteLoop();
startSubscribeWatcher();
return buildRoutingInfo(true, true); return buildRoutingInfo(true, true);
} catch (error) { } catch (error) {
@@ -148,6 +164,7 @@ export async function activateLinuxScreenShareAudioRouting(): Promise<LinuxScree
export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean> { export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean> {
const restoreSinkName = routingState.restoreSinkName; const restoreSinkName = routingState.restoreSinkName;
stopSubscribeWatcher();
stopSinkInputRerouteLoop(); stopSinkInputRerouteLoop();
await stopLinuxScreenShareMonitorCapture(); await stopLinuxScreenShareMonitorCapture();
@@ -166,6 +183,7 @@ export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean>
routingState.restoreSinkName = null; routingState.restoreSinkName = null;
routingState.screenShareLoopbackModuleId = null; routingState.screenShareLoopbackModuleId = null;
routingState.voiceLoopbackModuleId = null; routingState.voiceLoopbackModuleId = null;
routingState.subscribeProcess = null;
return true; return true;
} }
@@ -425,35 +443,53 @@ async function setDefaultSink(sinkName: string): Promise<void> {
await runPactl('set-default-sink', sinkName); await runPactl('set-default-sink', sinkName);
} }
async function rerouteAppSinkInputsToVoiceSink(): Promise<void> { /**
* Combined reroute that enforces sink placement in both directions:
* - App-owned sink inputs that are NOT on the voice sink are moved there.
* - Non-app sink inputs that ARE on the voice sink are moved to the
* screenshare sink so they are captured by parec.
*
* This two-way approach, combined with the voice sink being the PulseAudio
* default, ensures that received WebRTC voice audio can never leak into the
* screenshare monitor source.
*/
async function rerouteSinkInputs(): Promise<void> {
const [ const [
sinks, sinks,
sinkInputs, sinkInputs,
descendantProcessIds descendantProcessInfo
] = await Promise.all([ ] = await Promise.all([
listSinks(), listSinks(),
listSinkInputDetails(), listSinkInputDetails(),
collectDescendantProcessIds(process.pid) collectDescendantProcessInfo(process.pid)
]); ]);
const sinkNamesByIndex = new Map(sinks.map((sink) => [sink.index, sink.name])); const sinkNamesByIndex = new Map(sinks.map((sink) => [sink.index, sink.name]));
await Promise.all( await Promise.all(
sinkInputs.map(async (sinkInput) => { sinkInputs.map(async (sinkInput) => {
if (!isAppOwnedSinkInput(sinkInput, descendantProcessIds)) {
return;
}
const sinkName = sinkNamesByIndex.get(sinkInput.sinkIndex) ?? null; const sinkName = sinkNamesByIndex.get(sinkInput.sinkIndex) ?? null;
const appOwned = isAppOwnedSinkInput(sinkInput, descendantProcessInfo);
if (sinkName === VOICE_SINK_NAME) { // App-owned streams must stay on the voice sink.
return; if (appOwned && sinkName !== VOICE_SINK_NAME) {
}
try { try {
await runPactl('move-sink-input', sinkInput.index, VOICE_SINK_NAME); await runPactl('move-sink-input', sinkInput.index, VOICE_SINK_NAME);
} catch { } catch {
// Streams can disappear or be recreated while rerouting. // Streams can disappear or be recreated while rerouting.
} }
return;
}
// Non-app streams sitting on the voice sink should be moved to the
// screenshare sink for desktop-audio capture.
if (!appOwned && sinkName === VOICE_SINK_NAME) {
try {
await runPactl('move-sink-input', sinkInput.index, SCREEN_SHARE_SINK_NAME);
} catch {
// Streams can disappear or be recreated while rerouting.
}
}
}) })
); );
} }
@@ -515,7 +551,7 @@ function startSinkInputRerouteLoop(): void {
} }
routingState.rerouteIntervalId = setInterval(() => { routingState.rerouteIntervalId = setInterval(() => {
void rerouteAppSinkInputsToVoiceSink(); void rerouteSinkInputs();
}, REROUTE_INTERVAL_MS); }, REROUTE_INTERVAL_MS);
} }
@@ -528,13 +564,108 @@ function stopSinkInputRerouteLoop(): void {
routingState.rerouteIntervalId = null; routingState.rerouteIntervalId = null;
} }
/**
* Spawns `pactl subscribe` to receive PulseAudio events in real time.
* When a new or changed sink-input is detected, a reroute is triggered
* immediately instead of waiting for the next interval tick. This
* drastically reduces the time non-app desktop audio spends on the
* voice sink before being moved to the screenshare sink.
*/
function startSubscribeWatcher(): void {
if (routingState.subscribeProcess) {
return;
}
let proc: ChildProcess;
try {
proc = spawn('pactl', ['subscribe'], {
env: process.env,
stdio: [
'ignore',
'pipe',
'ignore'
]
});
} catch {
// If pactl subscribe fails to spawn, the interval loop still covers us.
return;
}
routingState.subscribeProcess = proc;
let pending = false;
proc.stdout?.on('data', (chunk: Buffer) => {
if (!routingState.active) {
return;
}
const text = chunk.toString();
if (/Event '(?:new|change)' on sink-input/.test(text)) {
if (!pending) {
pending = true;
// Batch rapid-fire events with a short delay.
setTimeout(() => {
pending = false;
void rerouteSinkInputs();
}, 50);
}
}
});
proc.on('close', () => {
if (routingState.subscribeProcess === proc) {
routingState.subscribeProcess = null;
}
});
proc.on('error', () => {
if (routingState.subscribeProcess === proc) {
routingState.subscribeProcess = null;
}
});
}
function stopSubscribeWatcher(): void {
const proc = routingState.subscribeProcess;
if (!proc) {
return;
}
routingState.subscribeProcess = null;
if (!proc.killed) {
proc.kill('SIGTERM');
}
}
function isAppOwnedSinkInput( function isAppOwnedSinkInput(
sinkInput: SinkInputDetails, sinkInput: SinkInputDetails,
descendantProcessIds: ReadonlySet<string> descendantProcessInfo: DescendantProcessInfo
): boolean { ): boolean {
const processId = sinkInput.properties['application.process.id']; const processId = sinkInput.properties['application.process.id'];
return typeof processId === 'string' && descendantProcessIds.has(processId); if (typeof processId === 'string' && descendantProcessInfo.ids.has(processId)) {
return true;
}
const processBinary = normalizeProcessBinary(sinkInput.properties['application.process.binary']);
if (processBinary && descendantProcessInfo.binaryNames.has(processBinary)) {
return true;
}
const applicationName = normalizeProcessBinary(sinkInput.properties['application.name']);
if (applicationName && descendantProcessInfo.binaryNames.has(applicationName)) {
return true;
}
return false;
} }
async function moveSinkInputs( async function moveSinkInputs(
@@ -697,31 +828,45 @@ async function listSinkInputDetails(): Promise<SinkInputDetails[]> {
return entries.filter((entry) => !!entry.sinkIndex); return entries.filter((entry) => !!entry.sinkIndex);
} }
async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<string>> { async function collectDescendantProcessInfo(rootProcessId: number): Promise<DescendantProcessInfo> {
const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid='], { const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid=,comm='], {
env: process.env env: process.env
}); });
const childrenByParentId = new Map<string, string[]>(); const childrenByParentId = new Map<string, string[]>();
const binaryNameByProcessId = new Map<string, string>();
stdout stdout
.split(/\r?\n/) .split(/\r?\n/)
.map((line) => line.trim()) .map((line) => line.trim())
.filter(Boolean) .filter(Boolean)
.forEach((line) => { .forEach((line) => {
const [pid, ppid] = line.split(/\s+/); const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
if (!pid || !ppid) { if (!match) {
return; return;
} }
const [
,
pid,
ppid,
command
] = match;
const siblings = childrenByParentId.get(ppid) ?? []; const siblings = childrenByParentId.get(ppid) ?? [];
siblings.push(pid); siblings.push(pid);
childrenByParentId.set(ppid, siblings); childrenByParentId.set(ppid, siblings);
const normalizedBinaryName = normalizeProcessBinary(command);
if (normalizedBinaryName) {
binaryNameByProcessId.set(pid, normalizedBinaryName);
}
}); });
const rootId = `${rootProcessId}`; const rootId = `${rootProcessId}`;
const descendantIds = new Set<string>([rootId]); const descendantIds = new Set<string>([rootId]);
const descendantBinaryNames = new Set<string>();
const queue = [rootId]; const queue = [rootId];
while (queue.length > 0) { while (queue.length > 0) {
@@ -731,6 +876,12 @@ async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<s
continue; continue;
} }
const binaryName = binaryNameByProcessId.get(currentId);
if (binaryName) {
descendantBinaryNames.add(binaryName);
}
for (const childId of childrenByParentId.get(currentId) ?? []) { for (const childId of childrenByParentId.get(currentId) ?? []) {
if (descendantIds.has(childId)) { if (descendantIds.has(childId)) {
continue; continue;
@@ -741,7 +892,30 @@ async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<s
} }
} }
return descendantIds; return {
ids: descendantIds,
binaryNames: descendantBinaryNames
};
}
function normalizeProcessBinary(value: string | undefined): string | null {
if (!value) {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const basename = trimmed
.split(/[\\/]/)
.pop()
?.trim()
.toLowerCase() ?? '';
return basename || null;
} }
function stripSurroundingQuotes(value: string): string { function stripSurroundingQuotes(value: string): string {

View File

@@ -10,7 +10,7 @@ interface WsMessage {
/** Sends the current user list for a given server to a single connected user. */ /** Sends the current user list for a given server to a single connected user. */
function sendServerUsers(user: ConnectedUser, serverId: string): void { function sendServerUsers(user: ConnectedUser, serverId: string): void {
const users = Array.from(connectedUsers.values()) const users = Array.from(connectedUsers.values())
.filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId && cu.displayName) .filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId)
.map(cu => ({ oderId: cu.oderId, displayName: cu.displayName ?? 'Anonymous' })); .map(cu => ({ oderId: cu.oderId, displayName: cu.displayName ?? 'Anonymous' }));
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users })); user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));

View File

@@ -9,13 +9,73 @@ import { connectedUsers } from './state';
import { broadcastToServer } from './broadcast'; import { broadcastToServer } from './broadcast';
import { handleWebSocketMessage } from './handler'; import { handleWebSocketMessage } from './handler';
/** How often to ping all connected clients (ms). */
const PING_INTERVAL_MS = 30_000;
/** Maximum time a client can go without a pong before we consider it dead (ms). */
const PONG_TIMEOUT_MS = 45_000;
function removeDeadConnection(connectionId: string): void {
const user = connectedUsers.get(connectionId);
if (user) {
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
user.serverIds.forEach((sid) => {
broadcastToServer(sid, {
type: 'user_left',
oderId: user.oderId,
displayName: user.displayName,
serverId: sid
}, user.oderId);
});
try {
user.ws.terminate();
} catch {
console.warn(`Failed to terminate WebSocket for ${user.displayName ?? 'Unknown'} (${user.oderId})`);
}
}
connectedUsers.delete(connectionId);
}
export function setupWebSocket(server: Server<typeof IncomingMessage, typeof ServerResponse>): void { export function setupWebSocket(server: Server<typeof IncomingMessage, typeof ServerResponse>): void {
const wss = new WebSocketServer({ server }); const wss = new WebSocketServer({ server });
// Periodically ping all clients and reap dead connections
const pingInterval = setInterval(() => {
const now = Date.now();
connectedUsers.forEach((user, connectionId) => {
if (now - user.lastPong > PONG_TIMEOUT_MS) {
removeDeadConnection(connectionId);
return;
}
if (user.ws.readyState === WebSocket.OPEN) {
try {
user.ws.ping();
} catch {
console.warn(`Failed to ping client ${user.displayName ?? 'Unknown'} (${user.oderId})`);
}
}
});
}, PING_INTERVAL_MS);
wss.on('close', () => clearInterval(pingInterval));
wss.on('connection', (ws: WebSocket) => { wss.on('connection', (ws: WebSocket) => {
const connectionId = uuidv4(); const connectionId = uuidv4();
const now = Date.now();
connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set() }); connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set(), lastPong: now });
ws.on('pong', () => {
const user = connectedUsers.get(connectionId);
if (user) {
user.lastPong = Date.now();
}
});
ws.on('message', (data) => { ws.on('message', (data) => {
try { try {
@@ -28,20 +88,7 @@ export function setupWebSocket(server: Server<typeof IncomingMessage, typeof Ser
}); });
ws.on('close', () => { ws.on('close', () => {
const user = connectedUsers.get(connectionId); removeDeadConnection(connectionId);
if (user) {
user.serverIds.forEach((sid) => {
broadcastToServer(sid, {
type: 'user_left',
oderId: user.oderId,
displayName: user.displayName,
serverId: sid
}, user.oderId);
});
}
connectedUsers.delete(connectionId);
}); });
ws.send(JSON.stringify({ type: 'connected', connectionId, serverTime: Date.now() })); ws.send(JSON.stringify({ type: 'connected', connectionId, serverTime: Date.now() }));

View File

@@ -6,4 +6,6 @@ export interface ConnectedUser {
serverIds: Set<string>; serverIds: Set<string>;
viewedServerId?: string; viewedServerId?: string;
displayName?: string; displayName?: string;
/** Timestamp of the last pong received (used to detect dead connections). */
lastPong: number;
} }

View File

@@ -218,7 +218,16 @@ export class DebuggingService {
const rawMessage = args.map((arg) => this.stringifyPreview(arg)).join(' ') const rawMessage = args.map((arg) => this.stringifyPreview(arg)).join(' ')
.trim() || '(empty console call)'; .trim() || '(empty console call)';
const consoleMetadata = this.extractConsoleMetadata(rawMessage);
// Use only string args for label/message extraction so that
// stringified object payloads don't pollute the parsed message.
// Object payloads are captured separately via extractConsolePayload.
const metadataSource = args
.filter((arg): arg is string => typeof arg === 'string')
.join(' ')
.trim() || rawMessage;
const consoleMetadata = this.extractConsoleMetadata(metadataSource);
const payload = this.extractConsolePayload(args); const payload = this.extractConsolePayload(args);
const payloadText = payload === undefined const payloadText = payload === undefined
? null ? null

View File

@@ -109,6 +109,7 @@ export class WebRTCService implements OnDestroy {
private readonly _isNoiseReductionEnabled = signal(false); private readonly _isNoiseReductionEnabled = signal(false);
private readonly _screenStreamSignal = signal<MediaStream | null>(null); private readonly _screenStreamSignal = signal<MediaStream | null>(null);
private readonly _isScreenShareRemotePlaybackSuppressed = signal(false); private readonly _isScreenShareRemotePlaybackSuppressed = signal(false);
private readonly _forceDefaultRemotePlaybackOutput = signal(false);
private readonly _hasConnectionError = signal(false); private readonly _hasConnectionError = signal(false);
private readonly _connectionErrorMessage = signal<string | null>(null); private readonly _connectionErrorMessage = signal<string | null>(null);
private readonly _hasEverConnected = signal(false); private readonly _hasEverConnected = signal(false);
@@ -131,6 +132,7 @@ export class WebRTCService implements OnDestroy {
readonly isNoiseReductionEnabled = computed(() => this._isNoiseReductionEnabled()); readonly isNoiseReductionEnabled = computed(() => this._isNoiseReductionEnabled());
readonly screenStream = computed(() => this._screenStreamSignal()); readonly screenStream = computed(() => this._screenStreamSignal());
readonly isScreenShareRemotePlaybackSuppressed = computed(() => this._isScreenShareRemotePlaybackSuppressed()); readonly isScreenShareRemotePlaybackSuppressed = computed(() => this._isScreenShareRemotePlaybackSuppressed());
readonly forceDefaultRemotePlaybackOutput = computed(() => this._forceDefaultRemotePlaybackOutput());
readonly hasConnectionError = computed(() => this._hasConnectionError()); readonly hasConnectionError = computed(() => this._hasConnectionError());
readonly connectionErrorMessage = computed(() => this._connectionErrorMessage()); readonly connectionErrorMessage = computed(() => this._connectionErrorMessage());
readonly shouldShowConnectionError = computed(() => { readonly shouldShowConnectionError = computed(() => {
@@ -220,6 +222,7 @@ export class WebRTCService implements OnDestroy {
this._isScreenSharing.set(state.active); this._isScreenSharing.set(state.active);
this._screenStreamSignal.set(state.stream); this._screenStreamSignal.set(state.stream);
this._isScreenShareRemotePlaybackSuppressed.set(state.suppressRemotePlayback); this._isScreenShareRemotePlaybackSuppressed.set(state.suppressRemotePlayback);
this._forceDefaultRemotePlaybackOutput.set(state.forceDefaultRemotePlaybackOutput);
} }
}); });
@@ -513,6 +516,11 @@ export class WebRTCService implements OnDestroy {
this.activeServerId = serverId; this.activeServerId = serverId;
} }
/** The server ID currently being viewed / active, or `null`. */
get currentServerId(): string | null {
return this.activeServerId;
}
/** /**
* Send an identify message to the signaling server. * Send an identify message to the signaling server.
* *
@@ -907,6 +915,7 @@ export class WebRTCService implements OnDestroy {
this._isScreenSharing.set(false); this._isScreenSharing.set(false);
this._screenStreamSignal.set(null); this._screenStreamSignal.set(null);
this._isScreenShareRemotePlaybackSuppressed.set(false); this._isScreenShareRemotePlaybackSuppressed.set(false);
this._forceDefaultRemotePlaybackOutput.set(false);
} }
/** Synchronise Angular signals from the MediaManager's internal state. */ /** Synchronise Angular signals from the MediaManager's internal state. */

View File

@@ -103,10 +103,10 @@ export class MediaManager {
* Replace the callback set at runtime. * Replace the callback set at runtime.
* Needed because of circular initialisation between managers. * Needed because of circular initialisation between managers.
* *
* @param cb - The new callback interface to wire into this manager. * @param nextCallbacks - The new callback interface to wire into this manager.
*/ */
setCallbacks(cb: MediaManagerCallbacks): void { setCallbacks(nextCallbacks: MediaManagerCallbacks): void {
this.callbacks = cb; this.callbacks = nextCallbacks;
} }
/** Returns the current local media stream, or `null` if voice is disabled. */ /** Returns the current local media stream, or `null` if voice is disabled. */
@@ -485,28 +485,21 @@ export class MediaManager {
if (!this.localMediaStream) if (!this.localMediaStream)
return; return;
const localAudioTrack = this.localMediaStream.getAudioTracks()[0] || null; const localStream = this.localMediaStream;
const localVideoTrack = this.localMediaStream.getVideoTracks()[0] || null; const localAudioTrack = localStream.getAudioTracks()[0] || null;
const localVideoTrack = localStream.getVideoTracks()[0] || null;
peers.forEach((peerData, peerId) => { peers.forEach((peerData, peerId) => {
if (localAudioTrack) { if (localAudioTrack) {
let audioSender = const audioTransceiver = this.getOrCreateReusableTransceiver(peerData, TRACK_KIND_AUDIO, {
peerData.audioSender || preferredSender: peerData.audioSender,
peerData.connection.getSenders().find((s) => s.track?.kind === TRACK_KIND_AUDIO); excludedSenders: [peerData.screenAudioSender]
});
if (!audioSender) { const audioSender = audioTransceiver.sender;
audioSender = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, {
direction: TRANSCEIVER_SEND_RECV
}).sender;
}
peerData.audioSender = audioSender; peerData.audioSender = audioSender;
// Restore direction after removeTrack (which sets it to recvonly) // Restore direction after removeTrack (which sets it to recvonly)
const audioTransceiver = peerData.connection
.getTransceivers()
.find((t) => t.sender === audioSender);
if ( if (
audioTransceiver && audioTransceiver &&
(audioTransceiver.direction === TRANSCEIVER_RECV_ONLY || (audioTransceiver.direction === TRANSCEIVER_RECV_ONLY ||
@@ -515,29 +508,25 @@ export class MediaManager {
audioTransceiver.direction = TRANSCEIVER_SEND_RECV; audioTransceiver.direction = TRANSCEIVER_SEND_RECV;
} }
if (typeof audioSender.setStreams === 'function') {
audioSender.setStreams(localStream);
}
audioSender audioSender
.replaceTrack(localAudioTrack) .replaceTrack(localAudioTrack)
.then(() => this.logger.info('audio replaceTrack ok', { peerId })) .then(() => this.logger.info('audio replaceTrack ok', { peerId }))
.catch((e) => this.logger.error('audio replaceTrack failed', e)); .catch((error) => this.logger.error('audio replaceTrack failed', error));
} }
if (localVideoTrack) { if (localVideoTrack) {
let videoSender = const videoTransceiver = this.getOrCreateReusableTransceiver(peerData, TRACK_KIND_VIDEO, {
peerData.videoSender || preferredSender: peerData.videoSender,
peerData.connection.getSenders().find((s) => s.track?.kind === TRACK_KIND_VIDEO); excludedSenders: [peerData.screenVideoSender]
});
if (!videoSender) { const videoSender = videoTransceiver.sender;
videoSender = peerData.connection.addTransceiver(TRACK_KIND_VIDEO, {
direction: TRANSCEIVER_SEND_RECV
}).sender;
}
peerData.videoSender = videoSender; peerData.videoSender = videoSender;
const videoTransceiver = peerData.connection
.getTransceivers()
.find((t) => t.sender === videoSender);
if ( if (
videoTransceiver && videoTransceiver &&
(videoTransceiver.direction === TRANSCEIVER_RECV_ONLY || (videoTransceiver.direction === TRANSCEIVER_RECV_ONLY ||
@@ -546,16 +535,64 @@ export class MediaManager {
videoTransceiver.direction = TRANSCEIVER_SEND_RECV; videoTransceiver.direction = TRANSCEIVER_SEND_RECV;
} }
if (typeof videoSender.setStreams === 'function') {
videoSender.setStreams(localStream);
}
videoSender videoSender
.replaceTrack(localVideoTrack) .replaceTrack(localVideoTrack)
.then(() => this.logger.info('video replaceTrack ok', { peerId })) .then(() => this.logger.info('video replaceTrack ok', { peerId }))
.catch((e) => this.logger.error('video replaceTrack failed', e)); .catch((error) => this.logger.error('video replaceTrack failed', error));
} }
this.callbacks.renegotiate(peerId); this.callbacks.renegotiate(peerId);
}); });
} }
private getOrCreateReusableTransceiver(
peerData: PeerData,
kind: typeof TRACK_KIND_AUDIO | typeof TRACK_KIND_VIDEO,
options: {
preferredSender?: RTCRtpSender;
excludedSenders?: (RTCRtpSender | undefined)[];
}
): RTCRtpTransceiver {
const excludedSenders = new Set(
(options.excludedSenders ?? []).filter((sender): sender is RTCRtpSender => !!sender)
);
const existingTransceivers = peerData.connection.getTransceivers();
const preferredTransceiver = options.preferredSender
? existingTransceivers.find((transceiver) => transceiver.sender === options.preferredSender)
: null;
if (preferredTransceiver) {
return preferredTransceiver;
}
const attachedSenderTransceiver = existingTransceivers.find((transceiver) =>
!excludedSenders.has(transceiver.sender)
&& transceiver.sender.track?.kind === kind
);
if (attachedSenderTransceiver) {
return attachedSenderTransceiver;
}
const reusableReceiverTransceiver = existingTransceivers.find((transceiver) =>
!excludedSenders.has(transceiver.sender)
&& !transceiver.sender.track
&& transceiver.receiver.track?.kind === kind
);
if (reusableReceiverTransceiver) {
return reusableReceiverTransceiver;
}
return peerData.connection.addTransceiver(kind, {
direction: TRANSCEIVER_SEND_RECV
});
}
/** Broadcast a voice-presence state event to all connected peers. */ /** Broadcast a voice-presence state event to all connected peers. */
private broadcastVoicePresence(): void { private broadcastVoicePresence(): void {
const oderId = this.callbacks.getIdentifyOderId(); const oderId = this.callbacks.getIdentifyOderId();

View File

@@ -127,7 +127,9 @@ export function createPeerConnection(
isInitiator, isInitiator,
pendingIceCandidates: [], pendingIceCandidates: [],
audioSender: undefined, audioSender: undefined,
videoSender: undefined videoSender: undefined,
remoteVoiceStreamIds: new Set<string>(),
remoteScreenShareStreamIds: new Set<string>()
}; };
if (isInitiator) { if (isInitiator) {
@@ -151,6 +153,10 @@ export function createPeerConnection(
localStream.getTracks().forEach((track) => { localStream.getTracks().forEach((track) => {
if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) { if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) {
if (typeof peerData.audioSender.setStreams === 'function') {
peerData.audioSender.setStreams(localStream);
}
peerData.audioSender peerData.audioSender
.replaceTrack(track) .replaceTrack(track)
.then(() => logger.info('audio replaceTrack (init) ok', { remotePeerId })) .then(() => logger.info('audio replaceTrack (init) ok', { remotePeerId }))
@@ -158,6 +164,10 @@ export function createPeerConnection(
logger.error('audio replaceTrack failed at createPeerConnection', error) logger.error('audio replaceTrack failed at createPeerConnection', error)
); );
} else if (track.kind === TRACK_KIND_VIDEO && peerData.videoSender) { } else if (track.kind === TRACK_KIND_VIDEO && peerData.videoSender) {
if (typeof peerData.videoSender.setStreams === 'function') {
peerData.videoSender.setStreams(localStream);
}
peerData.videoSender peerData.videoSender
.replaceTrack(track) .replaceTrack(track)
.then(() => logger.info('video replaceTrack (init) ok', { remotePeerId })) .then(() => logger.info('video replaceTrack (init) ok', { remotePeerId }))

View File

@@ -9,6 +9,7 @@ export function handleRemoteTrack(
): void { ): void {
const { logger, state } = context; const { logger, state } = context;
const track = event.track; const track = event.track;
const isScreenAudio = isScreenShareAudioTrack(context, event, remotePeerId);
const settings = const settings =
typeof track.getSettings === 'function' ? track.getSettings() : ({} as MediaTrackSettings); typeof track.getSettings === 'function' ? track.getSettings() : ({} as MediaTrackSettings);
@@ -34,10 +35,10 @@ export function handleRemoteTrack(
} }
const compositeStream = buildCompositeRemoteStream(state, remotePeerId, track); const compositeStream = buildCompositeRemoteStream(state, remotePeerId, track);
const voiceStream = isVoiceAudioTrack(context, event, remotePeerId) const voiceStream = isVoiceAudioTrack(track, isScreenAudio)
? buildAudioOnlyStream(state.remotePeerVoiceStreams.get(remotePeerId), track) ? buildAudioOnlyStream(state.remotePeerVoiceStreams.get(remotePeerId), track)
: null; : null;
const screenShareStream = isScreenShareTrack(context, event, remotePeerId) const screenShareStream = isScreenShareTrack(track, isScreenAudio)
? buildScreenShareStream(state.remotePeerScreenShareStreams.get(remotePeerId), track) ? buildScreenShareStream(state.remotePeerScreenShareStreams.get(remotePeerId), track)
: null; : null;
@@ -53,6 +54,12 @@ export function handleRemoteTrack(
state.remotePeerScreenShareStreams.set(remotePeerId, screenShareStream); state.remotePeerScreenShareStreams.set(remotePeerId, screenShareStream);
} }
rememberIncomingStreamIds(state, event, remotePeerId, {
isScreenAudio,
isVoiceAudio: !!voiceStream,
isScreenTrack: !!screenShareStream
});
publishRemoteStreamUpdate(context, remotePeerId, compositeStream); publishRemoteStreamUpdate(context, remotePeerId, compositeStream);
} }
@@ -61,6 +68,7 @@ export function clearRemoteScreenShareStream(
remotePeerId: string remotePeerId: string
): void { ): void {
const { state } = context; const { state } = context;
const peerData = state.activePeerConnections.get(remotePeerId);
const screenShareStream = state.remotePeerScreenShareStreams.get(remotePeerId); const screenShareStream = state.remotePeerScreenShareStreams.get(remotePeerId);
if (!screenShareStream) { if (!screenShareStream) {
@@ -79,6 +87,8 @@ export function clearRemoteScreenShareStream(
removeTracksFromStreamMap(state.remotePeerVoiceStreams, remotePeerId, screenShareTrackIds); removeTracksFromStreamMap(state.remotePeerVoiceStreams, remotePeerId, screenShareTrackIds);
state.remotePeerScreenShareStreams.delete(remotePeerId); state.remotePeerScreenShareStreams.delete(remotePeerId);
peerData?.remoteScreenShareStreamIds.clear();
publishRemoteStreamUpdate(context, remotePeerId, compositeStream); publishRemoteStreamUpdate(context, remotePeerId, compositeStream);
} }
@@ -152,11 +162,20 @@ function removeRemoteTrack(
trackId: string trackId: string
): void { ): void {
const { state } = context; const { state } = context;
const peerData = state.activePeerConnections.get(remotePeerId);
const compositeStream = removeTrackFromStreamMap(state.remotePeerStreams, remotePeerId, trackId); const compositeStream = removeTrackFromStreamMap(state.remotePeerStreams, remotePeerId, trackId);
removeTrackFromStreamMap(state.remotePeerVoiceStreams, remotePeerId, trackId); removeTrackFromStreamMap(state.remotePeerVoiceStreams, remotePeerId, trackId);
removeTrackFromStreamMap(state.remotePeerScreenShareStreams, remotePeerId, trackId); removeTrackFromStreamMap(state.remotePeerScreenShareStreams, remotePeerId, trackId);
if (!state.remotePeerVoiceStreams.has(remotePeerId)) {
peerData?.remoteVoiceStreamIds.clear();
}
if (!state.remotePeerScreenShareStreams.has(remotePeerId)) {
peerData?.remoteScreenShareStreamIds.clear();
}
publishRemoteStreamUpdate(context, remotePeerId, compositeStream); publishRemoteStreamUpdate(context, remotePeerId, compositeStream);
} }
@@ -224,20 +243,12 @@ function publishRemoteStreamUpdate(
}); });
} }
function isVoiceAudioTrack( function isVoiceAudioTrack(track: MediaStreamTrack, isScreenAudio: boolean): boolean {
context: PeerConnectionManagerContext, return track.kind === TRACK_KIND_AUDIO && !isScreenAudio;
event: RTCTrackEvent,
remotePeerId: string
): boolean {
return event.track.kind === TRACK_KIND_AUDIO && !isScreenShareAudioTrack(context, event, remotePeerId);
} }
function isScreenShareTrack( function isScreenShareTrack(track: MediaStreamTrack, isScreenAudio: boolean): boolean {
context: PeerConnectionManagerContext, return track.kind === TRACK_KIND_VIDEO || isScreenAudio;
event: RTCTrackEvent,
remotePeerId: string
): boolean {
return event.track.kind === TRACK_KIND_VIDEO || isScreenShareAudioTrack(context, event, remotePeerId);
} }
function isScreenShareAudioTrack( function isScreenShareAudioTrack(
@@ -255,12 +266,34 @@ function isScreenShareAudioTrack(
return false; return false;
} }
const incomingStreamIds = getIncomingStreamIds(event);
if (incomingStreamIds.some((streamId) => peerData.remoteScreenShareStreamIds.has(streamId))) {
return true;
}
if (incomingStreamIds.some((streamId) => peerData.remoteVoiceStreamIds.has(streamId))) {
return false;
}
if (event.streams.some((stream) => stream.getVideoTracks().some((track) => track.readyState === 'live'))) {
return true;
}
const screenAudioTransceiver = peerData.connection.getTransceivers().find(
(transceiver) => transceiver.sender === peerData.screenAudioSender
);
if (screenAudioTransceiver && matchesTransceiver(event.transceiver, screenAudioTransceiver)) {
return true;
}
const voiceAudioTransceiver = peerData.connection.getTransceivers().find( const voiceAudioTransceiver = peerData.connection.getTransceivers().find(
(transceiver) => transceiver.sender === peerData.audioSender (transceiver) => transceiver.sender === peerData.audioSender
); );
if (voiceAudioTransceiver) { if (voiceAudioTransceiver) {
return event.transceiver !== voiceAudioTransceiver; return !matchesTransceiver(event.transceiver, voiceAudioTransceiver);
} }
const audioTransceivers = peerData.connection.getTransceivers().filter((transceiver) => const audioTransceivers = peerData.connection.getTransceivers().filter((transceiver) =>
@@ -272,3 +305,52 @@ function isScreenShareAudioTrack(
return transceiverIndex > 0; return transceiverIndex > 0;
} }
function rememberIncomingStreamIds(
state: PeerConnectionManagerContext['state'],
event: RTCTrackEvent,
remotePeerId: string,
options: {
isScreenAudio: boolean;
isVoiceAudio: boolean;
isScreenTrack: boolean;
}
): void {
const peerData = state.activePeerConnections.get(remotePeerId);
if (!peerData) {
return;
}
const incomingStreamIds = getIncomingStreamIds(event);
if (incomingStreamIds.length === 0) {
return;
}
if (event.track.kind === TRACK_KIND_VIDEO || options.isScreenAudio || options.isScreenTrack) {
incomingStreamIds.forEach((streamId) => {
peerData.remoteScreenShareStreamIds.add(streamId);
peerData.remoteVoiceStreamIds.delete(streamId);
});
return;
}
if (options.isVoiceAudio) {
incomingStreamIds.forEach((streamId) => {
peerData.remoteVoiceStreamIds.add(streamId);
peerData.remoteScreenShareStreamIds.delete(streamId);
});
}
}
function getIncomingStreamIds(event: RTCTrackEvent): string[] {
return event.streams
.map((stream) => stream.id)
.filter((streamId): streamId is string => !!streamId);
}
function matchesTransceiver(left: RTCRtpTransceiver, right: RTCRtpTransceiver): boolean {
return left === right || (!!left.mid && !!right.mid && left.mid === right.mid);
}

View File

@@ -0,0 +1,56 @@
import { ScreenShareQualityPreset, ScreenShareStartOptions } from '../screen-share.config';
import { WebRTCLogger } from '../webrtc-logger';
export class BrowserScreenShareCapture {
constructor(private readonly logger: WebRTCLogger) {}
async startCapture(
options: ScreenShareStartOptions,
preset: ScreenShareQualityPreset
): Promise<MediaStream> {
const displayConstraints = this.buildDisplayMediaConstraints(options, preset);
this.logger.info('getDisplayMedia constraints', displayConstraints);
if (!navigator.mediaDevices?.getDisplayMedia) {
throw new Error('navigator.mediaDevices.getDisplayMedia is not available.');
}
return await navigator.mediaDevices.getDisplayMedia(displayConstraints);
}
private buildDisplayMediaConstraints(
options: ScreenShareStartOptions,
preset: ScreenShareQualityPreset
): DisplayMediaStreamOptions {
const supportedConstraints = navigator.mediaDevices?.getSupportedConstraints?.() as Record<string, boolean> | undefined;
const audioConstraints: Record<string, unknown> | false = options.includeSystemAudio
? {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false
}
: false;
if (audioConstraints && supportedConstraints?.['restrictOwnAudio']) {
audioConstraints['restrictOwnAudio'] = true;
}
if (audioConstraints && supportedConstraints?.['suppressLocalAudioPlayback']) {
audioConstraints['suppressLocalAudioPlayback'] = true;
}
return {
video: {
width: { ideal: preset.width, max: preset.width },
height: { ideal: preset.height, max: preset.height },
frameRate: { ideal: preset.frameRate, max: preset.frameRate }
},
audio: audioConstraints,
monitorTypeSurfaces: 'include',
selfBrowserSurface: 'exclude',
surfaceSwitching: 'include',
systemAudio: options.includeSystemAudio ? 'include' : 'exclude'
} as DisplayMediaStreamOptions;
}
}

View File

@@ -0,0 +1,163 @@
import { ScreenShareQualityPreset, ScreenShareStartOptions } from '../screen-share.config';
import { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from '../webrtc.constants';
import { WebRTCLogger } from '../webrtc-logger';
import {
DesktopSource,
ElectronDesktopCaptureResult,
ElectronDesktopMediaStreamConstraints,
ElectronDesktopSourceSelection,
ScreenShareElectronApi
} from './shared';
interface DesktopElectronScreenShareCaptureDependencies {
getElectronApi(): ScreenShareElectronApi | null;
getSelectDesktopSource(): ((
sources: readonly DesktopSource[],
options: { includeSystemAudio: boolean }
) => Promise<ElectronDesktopSourceSelection>) | undefined;
}
export class DesktopElectronScreenShareCapture {
constructor(
private readonly logger: WebRTCLogger,
private readonly dependencies: DesktopElectronScreenShareCaptureDependencies
) {}
isAvailable(): boolean {
return !!this.dependencies.getElectronApi()?.getSources && !this.isLinuxElectron();
}
shouldSuppressRemotePlaybackDuringShare(includeSystemAudio: boolean): boolean {
return includeSystemAudio && this.isWindowsElectron();
}
async startCapture(
options: ScreenShareStartOptions,
preset: ScreenShareQualityPreset
): Promise<ElectronDesktopCaptureResult> {
const electronApi = this.dependencies.getElectronApi();
if (!electronApi?.getSources) {
throw new Error('Electron desktop capture is unavailable.');
}
const sources = await electronApi.getSources();
const selection = await this.resolveSourceSelection(sources, options.includeSystemAudio);
const captureOptions = {
...options,
includeSystemAudio: selection.includeSystemAudio
};
if (!selection.source) {
throw new Error('No desktop capture sources were available.');
}
this.logger.info('Selected Electron desktop source', {
includeSystemAudio: selection.includeSystemAudio,
sourceId: selection.source.id,
sourceName: selection.source.name
});
const constraints = this.buildConstraints(selection.source.id, captureOptions, preset);
this.logger.info('desktopCapturer constraints', constraints);
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error('navigator.mediaDevices.getUserMedia is not available (requires HTTPS or localhost).');
}
return {
includeSystemAudio: selection.includeSystemAudio,
stream: await navigator.mediaDevices.getUserMedia(constraints)
};
}
private async resolveSourceSelection(
sources: DesktopSource[],
includeSystemAudio: boolean
): Promise<ElectronDesktopSourceSelection> {
const orderedSources = this.sortSources(sources);
const defaultSource = orderedSources.find((source) => source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME)
?? orderedSources[0];
if (orderedSources.length === 0) {
throw new Error('No desktop capture sources were available.');
}
const selectDesktopSource = this.dependencies.getSelectDesktopSource();
if (!this.isWindowsElectron() || orderedSources.length < 2 || !selectDesktopSource) {
return {
includeSystemAudio,
source: defaultSource
};
}
return await selectDesktopSource(orderedSources, { includeSystemAudio });
}
private sortSources(sources: DesktopSource[]): DesktopSource[] {
return [...sources].sort((left, right) => {
const weightDiff = this.getSourceWeight(left) - this.getSourceWeight(right);
if (weightDiff !== 0) {
return weightDiff;
}
return left.name.localeCompare(right.name);
});
}
private getSourceWeight(source: DesktopSource): number {
return source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME || source.id.startsWith('screen')
? 0
: 1;
}
private buildConstraints(
sourceId: string,
options: ScreenShareStartOptions,
preset: ScreenShareQualityPreset
): ElectronDesktopMediaStreamConstraints {
const constraints: ElectronDesktopMediaStreamConstraints = {
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: sourceId,
maxWidth: preset.width,
maxHeight: preset.height,
maxFrameRate: preset.frameRate
}
}
};
if (options.includeSystemAudio) {
constraints.audio = {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: sourceId
}
};
} else {
constraints.audio = false;
}
return constraints;
}
private isLinuxElectron(): boolean {
if (!this.dependencies.getElectronApi() || typeof navigator === 'undefined') {
return false;
}
return /linux/i.test(`${navigator.userAgent} ${navigator.platform}`);
}
private isWindowsElectron(): boolean {
if (!this.isAvailable() || typeof navigator === 'undefined') {
return false;
}
return /win/i.test(`${navigator.userAgent} ${navigator.platform}`);
}
}

View File

@@ -0,0 +1,439 @@
import { ScreenShareQualityPreset, ScreenShareStartOptions } from '../screen-share.config';
import { WebRTCLogger } from '../webrtc-logger';
import {
LinuxScreenShareAudioRoutingInfo,
LinuxScreenShareMonitorAudioChunkPayload,
LinuxScreenShareMonitorAudioEndedPayload,
LinuxScreenShareMonitorCaptureInfo,
ScreenShareElectronApi
} from './shared';
interface LinuxScreenShareMonitorAudioPipeline {
audioContext: AudioContext;
audioTrack: MediaStreamTrack;
bitsPerSample: number;
captureId: string;
channelCount: number;
mediaDestination: MediaStreamAudioDestinationNode;
nextStartTime: number;
pendingBytes: Uint8Array;
sampleRate: number;
unsubscribeChunk: () => void;
unsubscribeEnded: () => void;
}
interface LinuxElectronScreenShareCaptureDependencies {
getElectronApi(): ScreenShareElectronApi | null;
onCaptureEnded(): void;
startDisplayMedia(options: ScreenShareStartOptions, preset: ScreenShareQualityPreset): Promise<MediaStream>;
}
export class LinuxElectronScreenShareCapture {
private audioRoutingActive = false;
private audioRoutingResetPromise: Promise<void> | null = null;
private monitorAudioPipeline: LinuxScreenShareMonitorAudioPipeline | null = null;
constructor(
private readonly logger: WebRTCLogger,
private readonly dependencies: LinuxElectronScreenShareCaptureDependencies
) {}
isSupported(): boolean {
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
return false;
}
const electronApi = this.dependencies.getElectronApi();
const platformHint = `${navigator.userAgent} ${navigator.platform}`;
return !!electronApi?.prepareLinuxScreenShareAudioRouting
&& !!electronApi?.activateLinuxScreenShareAudioRouting
&& !!electronApi?.deactivateLinuxScreenShareAudioRouting
&& !!electronApi?.startLinuxScreenShareMonitorCapture
&& !!electronApi?.stopLinuxScreenShareMonitorCapture
&& !!electronApi?.onLinuxScreenShareMonitorAudioChunk
&& !!electronApi?.onLinuxScreenShareMonitorAudioEnded
&& /linux/i.test(platformHint);
}
async awaitPendingReset(): Promise<void> {
if (!this.audioRoutingResetPromise) {
return;
}
await this.audioRoutingResetPromise;
}
scheduleReset(): void {
if (!this.audioRoutingActive || this.audioRoutingResetPromise) {
return;
}
this.audioRoutingResetPromise = this.resetAudioRouting()
.catch((error) => {
this.logger.warn('Failed to reset Linux Electron audio routing', error);
})
.finally(() => {
this.audioRoutingResetPromise = null;
});
}
async startCapture(
options: ScreenShareStartOptions,
preset: ScreenShareQualityPreset
): Promise<MediaStream> {
const electronApi = this.getRequiredElectronApi();
const routingInfo = await electronApi.prepareLinuxScreenShareAudioRouting();
this.assertAudioRoutingReady(routingInfo, 'Linux Electron audio routing is unavailable.');
let desktopStream: MediaStream | null = null;
try {
const activation = await electronApi.activateLinuxScreenShareAudioRouting();
this.assertAudioRoutingReady(activation, 'Failed to activate Linux Electron audio routing.');
if (!activation.active) {
throw new Error(activation.reason || 'Failed to activate Linux Electron audio routing.');
}
desktopStream = await this.dependencies.startDisplayMedia({
...options,
includeSystemAudio: false
}, preset);
const { audioTrack, captureInfo } = await this.startMonitorTrack();
const stream = new MediaStream([...desktopStream.getVideoTracks(), audioTrack]);
desktopStream.getAudioTracks().forEach((track) => track.stop());
this.audioRoutingActive = true;
this.logger.info('Linux Electron screen-share audio routing enabled', {
screenShareMonitorSourceName: captureInfo.sourceName,
voiceSinkName: activation.voiceSinkName
});
return stream;
} catch (error) {
desktopStream?.getTracks().forEach((track) => track.stop());
await this.resetAudioRouting();
throw error;
}
}
private getRequiredElectronApi(): Required<Pick<
ScreenShareElectronApi,
| 'prepareLinuxScreenShareAudioRouting'
| 'activateLinuxScreenShareAudioRouting'
| 'deactivateLinuxScreenShareAudioRouting'
| 'startLinuxScreenShareMonitorCapture'
| 'stopLinuxScreenShareMonitorCapture'
| 'onLinuxScreenShareMonitorAudioChunk'
| 'onLinuxScreenShareMonitorAudioEnded'
>> {
const electronApi = this.dependencies.getElectronApi();
if (!electronApi?.prepareLinuxScreenShareAudioRouting
|| !electronApi.activateLinuxScreenShareAudioRouting
|| !electronApi.deactivateLinuxScreenShareAudioRouting
|| !electronApi.startLinuxScreenShareMonitorCapture
|| !electronApi.stopLinuxScreenShareMonitorCapture
|| !electronApi.onLinuxScreenShareMonitorAudioChunk
|| !electronApi.onLinuxScreenShareMonitorAudioEnded) {
throw new Error('Linux Electron audio routing is unavailable.');
}
return {
prepareLinuxScreenShareAudioRouting: electronApi.prepareLinuxScreenShareAudioRouting,
activateLinuxScreenShareAudioRouting: electronApi.activateLinuxScreenShareAudioRouting,
deactivateLinuxScreenShareAudioRouting: electronApi.deactivateLinuxScreenShareAudioRouting,
startLinuxScreenShareMonitorCapture: electronApi.startLinuxScreenShareMonitorCapture,
stopLinuxScreenShareMonitorCapture: electronApi.stopLinuxScreenShareMonitorCapture,
onLinuxScreenShareMonitorAudioChunk: electronApi.onLinuxScreenShareMonitorAudioChunk,
onLinuxScreenShareMonitorAudioEnded: electronApi.onLinuxScreenShareMonitorAudioEnded
};
}
private assertAudioRoutingReady(
routingInfo: LinuxScreenShareAudioRoutingInfo,
unavailableReason: string
): void {
if (!routingInfo.available) {
throw new Error(routingInfo.reason || unavailableReason);
}
if (!routingInfo.monitorCaptureSupported) {
throw new Error('Linux screen-share monitor capture requires restarting the desktop app so the new Electron main process can load.');
}
}
private async resetAudioRouting(): Promise<void> {
const electronApi = this.dependencies.getElectronApi();
const captureId = this.monitorAudioPipeline?.captureId;
this.audioRoutingActive = false;
this.disposeMonitorAudioPipeline();
try {
if (captureId && electronApi?.stopLinuxScreenShareMonitorCapture) {
await electronApi.stopLinuxScreenShareMonitorCapture(captureId);
}
} catch (error) {
this.logger.warn('Failed to stop Linux screen-share monitor capture', error);
}
try {
if (electronApi?.deactivateLinuxScreenShareAudioRouting) {
await electronApi.deactivateLinuxScreenShareAudioRouting();
}
} catch (error) {
this.logger.warn('Failed to deactivate Linux Electron audio routing', error);
}
}
private async startMonitorTrack(): Promise<{
audioTrack: MediaStreamTrack;
captureInfo: LinuxScreenShareMonitorCaptureInfo;
}> {
const electronApi = this.dependencies.getElectronApi();
if (!electronApi?.startLinuxScreenShareMonitorCapture
|| !electronApi?.stopLinuxScreenShareMonitorCapture
|| !electronApi?.onLinuxScreenShareMonitorAudioChunk
|| !electronApi?.onLinuxScreenShareMonitorAudioEnded) {
throw new Error('Linux screen-share monitor capture is unavailable.');
}
const queuedChunksByCaptureId = new Map<string, Uint8Array[]>();
const queuedEndedReasons = new Map<string, string | undefined>();
let pipeline: LinuxScreenShareMonitorAudioPipeline | null = null;
let captureInfo: LinuxScreenShareMonitorCaptureInfo | null = null;
const queueChunk = (captureId: string, chunk: Uint8Array): void => {
const queuedChunks = queuedChunksByCaptureId.get(captureId) || [];
queuedChunks.push(this.copyBytes(chunk));
queuedChunksByCaptureId.set(captureId, queuedChunks);
};
const onChunk = (payload: LinuxScreenShareMonitorAudioChunkPayload): void => {
if (!pipeline || payload.captureId !== pipeline.captureId) {
queueChunk(payload.captureId, payload.chunk);
return;
}
this.handleMonitorAudioChunk(pipeline, payload.chunk);
};
const onEnded = (payload: LinuxScreenShareMonitorAudioEndedPayload): void => {
if (!pipeline || payload.captureId !== pipeline.captureId) {
queuedEndedReasons.set(payload.captureId, payload.reason);
return;
}
this.logger.warn('Linux screen-share monitor capture ended', payload);
this.dependencies.onCaptureEnded();
};
const unsubscribeChunk = electronApi.onLinuxScreenShareMonitorAudioChunk(onChunk) as () => void;
const unsubscribeEnded = electronApi.onLinuxScreenShareMonitorAudioEnded(onEnded) as () => void;
try {
captureInfo = await electronApi.startLinuxScreenShareMonitorCapture() as LinuxScreenShareMonitorCaptureInfo;
const audioContext = new AudioContext({ sampleRate: captureInfo.sampleRate });
const mediaDestination = audioContext.createMediaStreamDestination();
await audioContext.resume();
const audioTrack = mediaDestination.stream.getAudioTracks()[0];
if (!audioTrack) {
throw new Error('Renderer audio pipeline did not produce a screen-share monitor track.');
}
pipeline = {
audioContext,
audioTrack,
bitsPerSample: captureInfo.bitsPerSample,
captureId: captureInfo.captureId,
channelCount: captureInfo.channelCount,
mediaDestination,
nextStartTime: audioContext.currentTime + 0.05,
pendingBytes: new Uint8Array(0),
sampleRate: captureInfo.sampleRate,
unsubscribeChunk,
unsubscribeEnded
};
this.monitorAudioPipeline = pipeline;
const activeCaptureId = captureInfo.captureId;
audioTrack.addEventListener('ended', () => {
if (this.monitorAudioPipeline?.captureId === activeCaptureId) {
this.dependencies.onCaptureEnded();
}
}, { once: true });
const queuedChunks = queuedChunksByCaptureId.get(captureInfo.captureId) || [];
const activePipeline = pipeline;
queuedChunks.forEach((chunk) => {
this.handleMonitorAudioChunk(activePipeline, chunk);
});
queuedChunksByCaptureId.delete(captureInfo.captureId);
if (queuedEndedReasons.has(captureInfo.captureId)) {
throw new Error(queuedEndedReasons.get(captureInfo.captureId)
|| 'Linux screen-share monitor capture ended before audio initialisation completed.');
}
return {
audioTrack,
captureInfo
};
} catch (error) {
if (pipeline) {
this.disposeMonitorAudioPipeline(pipeline.captureId);
} else {
unsubscribeChunk();
unsubscribeEnded();
}
try {
await electronApi.stopLinuxScreenShareMonitorCapture(captureInfo?.captureId);
} catch (stopError) {
this.logger.warn('Failed to stop Linux screen-share monitor capture after startup failure', stopError);
}
throw error;
}
}
private disposeMonitorAudioPipeline(captureId?: string): void {
if (!this.monitorAudioPipeline) {
return;
}
if (captureId && captureId !== this.monitorAudioPipeline.captureId) {
return;
}
const pipeline = this.monitorAudioPipeline;
this.monitorAudioPipeline = null;
pipeline.unsubscribeChunk();
pipeline.unsubscribeEnded();
pipeline.audioTrack.stop();
pipeline.pendingBytes = new Uint8Array(0);
void pipeline.audioContext.close().catch((error) => {
this.logger.warn('Failed to close Linux screen-share monitor audio context', error);
});
}
private handleMonitorAudioChunk(
pipeline: LinuxScreenShareMonitorAudioPipeline,
chunk: Uint8Array
): void {
if (pipeline.bitsPerSample !== 16) {
this.logger.warn('Unsupported Linux screen-share monitor capture sample size', {
bitsPerSample: pipeline.bitsPerSample,
captureId: pipeline.captureId
});
return;
}
const bytesPerSample = pipeline.bitsPerSample / 8;
const bytesPerFrame = bytesPerSample * pipeline.channelCount;
if (!Number.isFinite(bytesPerFrame) || bytesPerFrame <= 0) {
return;
}
const combinedBytes = this.concatBytes(pipeline.pendingBytes, chunk);
const completeByteLength = combinedBytes.byteLength - (combinedBytes.byteLength % bytesPerFrame);
if (completeByteLength <= 0) {
pipeline.pendingBytes = combinedBytes;
return;
}
const completeBytes = combinedBytes.subarray(0, completeByteLength);
pipeline.pendingBytes = this.copyBytes(combinedBytes.subarray(completeByteLength));
if (pipeline.audioContext.state !== 'running') {
void pipeline.audioContext.resume().catch((error) => {
this.logger.warn('Failed to resume Linux screen-share monitor audio context', error);
});
}
const frameCount = completeByteLength / bytesPerFrame;
const audioBuffer = this.createAudioBuffer(pipeline, completeBytes, frameCount);
const source = pipeline.audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(pipeline.mediaDestination);
source.onended = () => {
source.disconnect();
};
const now = pipeline.audioContext.currentTime;
const startTime = Math.max(pipeline.nextStartTime, now + 0.02);
source.start(startTime);
pipeline.nextStartTime = startTime + audioBuffer.duration;
}
private createAudioBuffer(
pipeline: LinuxScreenShareMonitorAudioPipeline,
bytes: Uint8Array,
frameCount: number
): AudioBuffer {
const audioBuffer = pipeline.audioContext.createBuffer(pipeline.channelCount, frameCount, pipeline.sampleRate);
const sampleData = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const channelData = Array.from(
{ length: pipeline.channelCount },
(_, channelIndex) => audioBuffer.getChannelData(channelIndex)
);
const bytesPerSample = pipeline.bitsPerSample / 8;
const bytesPerFrame = bytesPerSample * pipeline.channelCount;
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
const frameOffset = frameIndex * bytesPerFrame;
for (let channelIndex = 0; channelIndex < pipeline.channelCount; channelIndex += 1) {
const sampleOffset = frameOffset + (channelIndex * bytesPerSample);
channelData[channelIndex][frameIndex] = sampleData.getInt16(sampleOffset, true) / 32768;
}
}
return audioBuffer;
}
private concatBytes(first: Uint8Array, second: Uint8Array): Uint8Array {
if (first.byteLength === 0) {
return this.copyBytes(second);
}
if (second.byteLength === 0) {
return this.copyBytes(first);
}
const combined = new Uint8Array(first.byteLength + second.byteLength);
combined.set(first, 0);
combined.set(second, first.byteLength);
return combined;
}
private copyBytes(bytes: Uint8Array): Uint8Array {
return bytes.byteLength > 0 ? new Uint8Array(bytes) : new Uint8Array(0);
}
}

View File

@@ -0,0 +1,80 @@
export interface DesktopSource {
id: string;
name: string;
thumbnail: string;
}
export interface ElectronDesktopSourceSelection {
includeSystemAudio: boolean;
source: DesktopSource;
}
export interface ElectronDesktopCaptureResult {
includeSystemAudio: boolean;
stream: MediaStream;
}
export interface LinuxScreenShareAudioRoutingInfo {
available: boolean;
active: boolean;
monitorCaptureSupported: boolean;
screenShareSinkName: string;
screenShareMonitorSourceName: string;
voiceSinkName: string;
reason?: string;
}
export interface LinuxScreenShareMonitorCaptureInfo {
bitsPerSample: number;
captureId: string;
channelCount: number;
sampleRate: number;
sourceName: string;
}
export interface LinuxScreenShareMonitorAudioChunkPayload {
captureId: string;
chunk: Uint8Array;
}
export interface LinuxScreenShareMonitorAudioEndedPayload {
captureId: string;
reason?: string;
}
export interface ScreenShareElectronApi {
getSources?: () => Promise<DesktopSource[]>;
prepareLinuxScreenShareAudioRouting?: () => Promise<LinuxScreenShareAudioRoutingInfo>;
activateLinuxScreenShareAudioRouting?: () => Promise<LinuxScreenShareAudioRoutingInfo>;
deactivateLinuxScreenShareAudioRouting?: () => Promise<boolean>;
startLinuxScreenShareMonitorCapture?: () => Promise<LinuxScreenShareMonitorCaptureInfo>;
stopLinuxScreenShareMonitorCapture?: (captureId?: string) => Promise<boolean>;
onLinuxScreenShareMonitorAudioChunk?: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
onLinuxScreenShareMonitorAudioEnded?: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
}
export type ElectronDesktopVideoConstraint = MediaTrackConstraints & {
mandatory: {
chromeMediaSource: 'desktop';
chromeMediaSourceId: string;
maxWidth: number;
maxHeight: number;
maxFrameRate: number;
};
};
export type ElectronDesktopAudioConstraint = MediaTrackConstraints & {
mandatory: {
chromeMediaSource: 'desktop';
chromeMediaSourceId: string;
};
};
export interface ElectronDesktopMediaStreamConstraints extends MediaStreamConstraints {
video: ElectronDesktopVideoConstraint;
audio?: false | ElectronDesktopAudioConstraint;
}
export type ScreenShareWindow = Window & {
electronAPI?: ScreenShareElectronApi;
};

View File

@@ -9,8 +9,7 @@ import {
TRACK_KIND_AUDIO, TRACK_KIND_AUDIO,
TRACK_KIND_VIDEO, TRACK_KIND_VIDEO,
TRANSCEIVER_SEND_RECV, TRANSCEIVER_SEND_RECV,
TRANSCEIVER_RECV_ONLY, TRANSCEIVER_RECV_ONLY
ELECTRON_ENTIRE_SCREEN_SOURCE_NAME
} from './webrtc.constants'; } from './webrtc.constants';
import { import {
DEFAULT_SCREEN_SHARE_START_OPTIONS, DEFAULT_SCREEN_SHARE_START_OPTIONS,
@@ -18,6 +17,10 @@ import {
ScreenShareQualityPreset, ScreenShareQualityPreset,
ScreenShareStartOptions ScreenShareStartOptions
} from './screen-share.config'; } from './screen-share.config';
import { BrowserScreenShareCapture } from './screen-share-platforms/browser-screen-share.capture';
import { DesktopElectronScreenShareCapture } from './screen-share-platforms/desktop-electron-screen-share.capture';
import { LinuxElectronScreenShareCapture } from './screen-share-platforms/linux-electron-screen-share.capture';
import { ScreenShareElectronApi, ScreenShareWindow } from './screen-share-platforms/shared';
/** /**
* Callbacks the ScreenShareManager needs from the owning service. * Callbacks the ScreenShareManager needs from the owning service.
@@ -45,103 +48,9 @@ export interface LocalScreenShareState {
includeSystemAudio: boolean; includeSystemAudio: boolean;
stream: MediaStream | null; stream: MediaStream | null;
suppressRemotePlayback: boolean; suppressRemotePlayback: boolean;
forceDefaultRemotePlaybackOutput: boolean;
} }
interface LinuxScreenShareAudioRoutingInfo {
available: boolean;
active: boolean;
monitorCaptureSupported: boolean;
screenShareSinkName: string;
screenShareMonitorSourceName: string;
voiceSinkName: string;
reason?: string;
}
interface LinuxScreenShareMonitorCaptureInfo {
bitsPerSample: number;
captureId: string;
channelCount: number;
sampleRate: number;
sourceName: string;
}
interface LinuxScreenShareMonitorAudioChunkPayload {
captureId: string;
chunk: Uint8Array;
}
interface LinuxScreenShareMonitorAudioEndedPayload {
captureId: string;
reason?: string;
}
interface LinuxScreenShareMonitorAudioPipeline {
audioContext: AudioContext;
audioTrack: MediaStreamTrack;
bitsPerSample: number;
captureId: string;
channelCount: number;
mediaDestination: MediaStreamAudioDestinationNode;
nextStartTime: number;
pendingBytes: Uint8Array;
sampleRate: number;
unsubscribeChunk: () => void;
unsubscribeEnded: () => void;
}
export interface DesktopSource {
id: string;
name: string;
thumbnail: string;
}
interface ElectronDesktopSourceSelection {
includeSystemAudio: boolean;
source: DesktopSource;
}
interface ElectronDesktopCaptureResult {
includeSystemAudio: boolean;
stream: MediaStream;
}
interface ScreenShareElectronApi {
getSources?: () => Promise<DesktopSource[]>;
prepareLinuxScreenShareAudioRouting?: () => Promise<LinuxScreenShareAudioRoutingInfo>;
activateLinuxScreenShareAudioRouting?: () => Promise<LinuxScreenShareAudioRoutingInfo>;
deactivateLinuxScreenShareAudioRouting?: () => Promise<boolean>;
startLinuxScreenShareMonitorCapture?: () => Promise<LinuxScreenShareMonitorCaptureInfo>;
stopLinuxScreenShareMonitorCapture?: (captureId?: string) => Promise<boolean>;
onLinuxScreenShareMonitorAudioChunk?: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
onLinuxScreenShareMonitorAudioEnded?: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
}
type ElectronDesktopVideoConstraint = MediaTrackConstraints & {
mandatory: {
chromeMediaSource: 'desktop';
chromeMediaSourceId: string;
maxWidth: number;
maxHeight: number;
maxFrameRate: number;
};
};
type ElectronDesktopAudioConstraint = MediaTrackConstraints & {
mandatory: {
chromeMediaSource: 'desktop';
chromeMediaSourceId: string;
};
};
interface ElectronDesktopMediaStreamConstraints extends MediaStreamConstraints {
video: ElectronDesktopVideoConstraint;
audio?: false | ElectronDesktopAudioConstraint;
}
type ScreenShareWindow = Window & {
electronAPI?: ScreenShareElectronApi;
};
export class ScreenShareManager { export class ScreenShareManager {
/** The active screen-capture stream. */ /** The active screen-capture stream. */
private activeScreenStream: MediaStream | null = null; private activeScreenStream: MediaStream | null = null;
@@ -155,22 +64,39 @@ export class ScreenShareManager {
/** Remote peers that explicitly requested screen-share video. */ /** Remote peers that explicitly requested screen-share video. */
private readonly requestedViewerPeerIds = new Set<string>(); private readonly requestedViewerPeerIds = new Set<string>();
/** Browser `getDisplayMedia` capture path. */
private readonly browserScreenShareCapture: BrowserScreenShareCapture;
/** Desktop Electron capture path for non-Linux desktop builds. */
private readonly desktopElectronScreenShareCapture: DesktopElectronScreenShareCapture;
/** Linux Electron screen/audio capture path with isolated audio routing. */
private readonly linuxElectronScreenShareCapture: LinuxElectronScreenShareCapture;
/** Whether screen sharing is currently active. */ /** Whether screen sharing is currently active. */
private isScreenActive = false; private isScreenActive = false;
/** Whether Linux-specific Electron audio routing is currently active. */
private linuxElectronAudioRoutingActive = false;
/** Pending teardown of Linux-specific Electron audio routing. */
private linuxAudioRoutingResetPromise: Promise<void> | null = null;
/** Renderer-side audio pipeline for Linux monitor-source capture. */
private linuxMonitorAudioPipeline: LinuxScreenShareMonitorAudioPipeline | null = null;
constructor( constructor(
private readonly logger: WebRTCLogger, private readonly logger: WebRTCLogger,
private callbacks: ScreenShareCallbacks private callbacks: ScreenShareCallbacks
) {} ) {
this.browserScreenShareCapture = new BrowserScreenShareCapture(this.logger);
this.desktopElectronScreenShareCapture = new DesktopElectronScreenShareCapture(this.logger, {
getElectronApi: () => this.getElectronApi(),
getSelectDesktopSource: () => this.callbacks.selectDesktopSource
});
this.linuxElectronScreenShareCapture = new LinuxElectronScreenShareCapture(this.logger, {
getElectronApi: () => this.getElectronApi(),
onCaptureEnded: () => {
if (this.isScreenActive) {
this.stopScreenShare();
}
},
startDisplayMedia: async (options, preset) =>
await this.browserScreenShareCapture.startCapture(options, preset)
});
}
/** /**
* Replace the callback set at runtime. * Replace the callback set at runtime.
@@ -192,8 +118,10 @@ export class ScreenShareManager {
* *
* On Linux Electron builds, prefers a dedicated PulseAudio/PipeWire routing * On Linux Electron builds, prefers a dedicated PulseAudio/PipeWire routing
* path so remote voice playback is kept out of captured system audio. * path so remote voice playback is kept out of captured system audio.
* On other Electron builds, uses desktop capture. In browser contexts, uses * On Windows Electron builds, prefers `getDisplayMedia` with system audio
* `getDisplayMedia`. * so the separate mic `getUserMedia` stream is not disrupted; falls back to
* Electron desktop capture only when `getDisplayMedia` fails entirely.
* In browser contexts, uses `getDisplayMedia`.
* *
* @param options - Screen-share capture options. * @param options - Screen-share capture options.
* @returns The captured screen {@link MediaStream}. * @returns The captured screen {@link MediaStream}.
@@ -205,7 +133,7 @@ export class ScreenShareManager {
...options ...options
}; };
const preset = SCREEN_SHARE_QUALITY_PRESETS[shareOptions.quality]; const preset = SCREEN_SHARE_QUALITY_PRESETS[shareOptions.quality];
const electronDesktopCaptureAvailable = this.isElectronDesktopCaptureAvailable(); const electronDesktopCaptureAvailable = this.desktopElectronScreenShareCapture.isAvailable();
let captureMethod: ScreenShareCaptureMethod | null = null; let captureMethod: ScreenShareCaptureMethod | null = null;
@@ -216,13 +144,13 @@ export class ScreenShareManager {
this.stopScreenShare(); this.stopScreenShare();
} }
await this.awaitPendingLinuxAudioRoutingReset(); await this.linuxElectronScreenShareCapture.awaitPendingReset();
this.activeScreenStream = null; this.activeScreenStream = null;
if (shareOptions.includeSystemAudio && this.isLinuxElectronAudioRoutingSupported()) { if (shareOptions.includeSystemAudio && this.linuxElectronScreenShareCapture.isSupported()) {
try { try {
this.activeScreenStream = await this.startWithLinuxElectronAudioRouting(shareOptions, preset); this.activeScreenStream = await this.linuxElectronScreenShareCapture.startCapture(shareOptions, preset);
captureMethod = 'linux-electron'; captureMethod = 'linux-electron';
} catch (error) { } catch (error) {
this.rethrowIfScreenShareAborted(error); this.rethrowIfScreenShareAborted(error);
@@ -230,17 +158,29 @@ export class ScreenShareManager {
} }
} }
if (!this.activeScreenStream && shareOptions.includeSystemAudio && !electronDesktopCaptureAvailable) { if (!this.activeScreenStream && shareOptions.includeSystemAudio) {
try { try {
this.activeScreenStream = await this.startWithDisplayMedia(shareOptions, preset); this.activeScreenStream = await this.browserScreenShareCapture.startCapture(shareOptions, preset);
captureMethod = 'display-media'; captureMethod = 'display-media';
if (this.activeScreenStream.getAudioTracks().length === 0) { if (this.activeScreenStream.getAudioTracks().length === 0) {
this.logger.warn('getDisplayMedia did not provide system audio; trying Electron desktop capture'); if (electronDesktopCaptureAvailable) {
// On Windows Electron, keep the getDisplayMedia stream for video
// rather than falling through to getUserMedia desktop audio which
// can replace or kill the active mic stream.
this.logger.warn(
'getDisplayMedia did not provide system audio; '
+ 'continuing without system audio to preserve mic stream'
);
shareOptions.includeSystemAudio = false;
} else {
this.logger.warn('getDisplayMedia did not provide system audio; trying next capture method');
this.activeScreenStream.getTracks().forEach((track) => track.stop()); this.activeScreenStream.getTracks().forEach((track) => track.stop());
this.activeScreenStream = null; this.activeScreenStream = null;
captureMethod = null; captureMethod = null;
} }
}
} catch (error) { } catch (error) {
this.rethrowIfScreenShareAborted(error); this.rethrowIfScreenShareAborted(error);
this.logger.warn('getDisplayMedia with system audio failed; falling back to Electron desktop capture', error); this.logger.warn('getDisplayMedia with system audio failed; falling back to Electron desktop capture', error);
@@ -249,7 +189,7 @@ export class ScreenShareManager {
if (!this.activeScreenStream && electronDesktopCaptureAvailable) { if (!this.activeScreenStream && electronDesktopCaptureAvailable) {
try { try {
const electronCapture = await this.startWithElectronDesktopCapturer(shareOptions, preset); const electronCapture = await this.desktopElectronScreenShareCapture.startCapture(shareOptions, preset);
this.activeScreenStream = electronCapture.stream; this.activeScreenStream = electronCapture.stream;
shareOptions.includeSystemAudio = electronCapture.includeSystemAudio; shareOptions.includeSystemAudio = electronCapture.includeSystemAudio;
@@ -261,7 +201,7 @@ export class ScreenShareManager {
} }
if (!this.activeScreenStream) { if (!this.activeScreenStream) {
this.activeScreenStream = await this.startWithDisplayMedia(shareOptions, preset); this.activeScreenStream = await this.browserScreenShareCapture.startCapture(shareOptions, preset);
captureMethod = 'display-media'; captureMethod = 'display-media';
} }
@@ -308,7 +248,7 @@ export class ScreenShareManager {
this.activeScreenStream = null; this.activeScreenStream = null;
} }
this.scheduleLinuxAudioRoutingReset(); this.linuxElectronScreenShareCapture.scheduleReset();
this.screenAudioStream = null; this.screenAudioStream = null;
this.activeScreenPreset = null; this.activeScreenPreset = null;
@@ -390,26 +330,6 @@ export class ScreenShareManager {
: null; : null;
} }
private isElectronDesktopCaptureAvailable(): boolean {
return !!this.getElectronApi()?.getSources && !this.isLinuxElectron();
}
private isLinuxElectron(): boolean {
if (!this.getElectronApi() || typeof navigator === 'undefined') {
return false;
}
return /linux/i.test(`${navigator.userAgent} ${navigator.platform}`);
}
private isWindowsElectron(): boolean {
if (!this.isElectronDesktopCaptureAvailable() || typeof navigator === 'undefined') {
return false;
}
return /win/i.test(`${navigator.userAgent} ${navigator.platform}`);
}
private publishLocalScreenShareState( private publishLocalScreenShareState(
includeSystemAudio: boolean, includeSystemAudio: boolean,
captureMethod: ScreenShareCaptureMethod | null captureMethod: ScreenShareCaptureMethod | null
@@ -420,63 +340,13 @@ export class ScreenShareManager {
includeSystemAudio: this.isScreenActive ? includeSystemAudio : false, includeSystemAudio: this.isScreenActive ? includeSystemAudio : false,
stream: this.isScreenActive ? this.activeScreenStream : null, stream: this.isScreenActive ? this.activeScreenStream : null,
suppressRemotePlayback: this.isScreenActive suppressRemotePlayback: this.isScreenActive
&& this.shouldSuppressRemotePlaybackDuringShare(includeSystemAudio, captureMethod) && this.desktopElectronScreenShareCapture.shouldSuppressRemotePlaybackDuringShare(includeSystemAudio),
forceDefaultRemotePlaybackOutput: this.isScreenActive
&& includeSystemAudio
&& captureMethod === 'linux-electron'
}); });
} }
private shouldSuppressRemotePlaybackDuringShare(
includeSystemAudio: boolean,
captureMethod: ScreenShareCaptureMethod | null
): boolean {
return includeSystemAudio && captureMethod === 'electron-desktop' && this.isWindowsElectron();
}
private getRequiredLinuxElectronApi(): Required<Pick<
ScreenShareElectronApi,
| 'prepareLinuxScreenShareAudioRouting'
| 'activateLinuxScreenShareAudioRouting'
| 'deactivateLinuxScreenShareAudioRouting'
| 'startLinuxScreenShareMonitorCapture'
| 'stopLinuxScreenShareMonitorCapture'
| 'onLinuxScreenShareMonitorAudioChunk'
| 'onLinuxScreenShareMonitorAudioEnded'
>> {
const electronApi = this.getElectronApi();
if (!electronApi?.prepareLinuxScreenShareAudioRouting
|| !electronApi.activateLinuxScreenShareAudioRouting
|| !electronApi.deactivateLinuxScreenShareAudioRouting
|| !electronApi.startLinuxScreenShareMonitorCapture
|| !electronApi.stopLinuxScreenShareMonitorCapture
|| !electronApi.onLinuxScreenShareMonitorAudioChunk
|| !electronApi.onLinuxScreenShareMonitorAudioEnded) {
throw new Error('Linux Electron audio routing is unavailable.');
}
return {
prepareLinuxScreenShareAudioRouting: electronApi.prepareLinuxScreenShareAudioRouting,
activateLinuxScreenShareAudioRouting: electronApi.activateLinuxScreenShareAudioRouting,
deactivateLinuxScreenShareAudioRouting: electronApi.deactivateLinuxScreenShareAudioRouting,
startLinuxScreenShareMonitorCapture: electronApi.startLinuxScreenShareMonitorCapture,
stopLinuxScreenShareMonitorCapture: electronApi.stopLinuxScreenShareMonitorCapture,
onLinuxScreenShareMonitorAudioChunk: electronApi.onLinuxScreenShareMonitorAudioChunk,
onLinuxScreenShareMonitorAudioEnded: electronApi.onLinuxScreenShareMonitorAudioEnded
};
}
private assertLinuxAudioRoutingReady(
routingInfo: LinuxScreenShareAudioRoutingInfo,
unavailableReason: string
): void {
if (!routingInfo.available) {
throw new Error(routingInfo.reason || unavailableReason);
}
if (!routingInfo.monitorCaptureSupported) {
throw new Error('Linux screen-share monitor capture requires restarting the desktop app so the new Electron main process can load.');
}
}
/** /**
* Create a dedicated stream for system audio captured alongside the screen. * Create a dedicated stream for system audio captured alongside the screen.
* *
@@ -555,6 +425,11 @@ export class ScreenShareManager {
} }
peerData.screenVideoSender = videoSender; peerData.screenVideoSender = videoSender;
if (typeof videoSender.setStreams === 'function') {
videoSender.setStreams(this.activeScreenStream);
}
videoSender.replaceTrack(screenVideoTrack) videoSender.replaceTrack(screenVideoTrack)
.then(() => { .then(() => {
this.logger.info('screen video replaceTrack ok', { peerId }); this.logger.info('screen video replaceTrack ok', { peerId });
@@ -585,6 +460,11 @@ export class ScreenShareManager {
} }
peerData.screenAudioSender = screenAudioSender; peerData.screenAudioSender = screenAudioSender;
if (typeof screenAudioSender.setStreams === 'function') {
screenAudioSender.setStreams(this.activeScreenStream);
}
screenAudioSender.replaceTrack(screenAudioTrack) screenAudioSender.replaceTrack(screenAudioTrack)
.then(() => this.logger.info('screen audio replaceTrack ok', { peerId })) .then(() => this.logger.info('screen audio replaceTrack ok', { peerId }))
.catch((error) => this.logger.error('screen audio replaceTrack failed', error)); .catch((error) => this.logger.error('screen audio replaceTrack failed', error));
@@ -628,109 +508,6 @@ export class ScreenShareManager {
this.callbacks.renegotiate(peerId); this.callbacks.renegotiate(peerId);
} }
private async startWithDisplayMedia(
options: ScreenShareStartOptions,
preset: ScreenShareQualityPreset
): Promise<MediaStream> {
const displayConstraints = this.buildDisplayMediaConstraints(options, preset);
this.logger.info('getDisplayMedia constraints', displayConstraints);
if (!navigator.mediaDevices?.getDisplayMedia) {
throw new Error('navigator.mediaDevices.getDisplayMedia is not available.');
}
return await navigator.mediaDevices.getDisplayMedia(displayConstraints);
}
private async startWithElectronDesktopCapturer(
options: ScreenShareStartOptions,
preset: ScreenShareQualityPreset
): Promise<ElectronDesktopCaptureResult> {
const electronApi = this.getElectronApi();
if (!electronApi?.getSources) {
throw new Error('Electron desktop capture is unavailable.');
}
const sources = await electronApi.getSources();
const selection = await this.resolveElectronDesktopSource(sources, options.includeSystemAudio);
const captureOptions = {
...options,
includeSystemAudio: selection.includeSystemAudio
};
if (!selection.source) {
throw new Error('No desktop capture sources were available.');
}
this.logger.info('Selected Electron desktop source', {
includeSystemAudio: selection.includeSystemAudio,
sourceId: selection.source.id,
sourceName: selection.source.name
});
const electronConstraints = this.buildElectronDesktopConstraints(selection.source.id, captureOptions, preset);
this.logger.info('desktopCapturer constraints', electronConstraints);
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error('navigator.mediaDevices.getUserMedia is not available (requires HTTPS or localhost).');
}
return {
includeSystemAudio: selection.includeSystemAudio,
stream: await navigator.mediaDevices.getUserMedia(electronConstraints)
};
}
private async resolveElectronDesktopSource(
sources: DesktopSource[],
includeSystemAudio: boolean
): Promise<ElectronDesktopSourceSelection> {
const orderedSources = this.sortElectronDesktopSources(sources);
const defaultSource = orderedSources.find((source) => source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME)
?? orderedSources[0];
if (orderedSources.length === 0) {
throw new Error('No desktop capture sources were available.');
}
if (!this.isWindowsElectron() || orderedSources.length < 2) {
return {
includeSystemAudio,
source: defaultSource
};
}
if (!this.callbacks.selectDesktopSource) {
return {
includeSystemAudio,
source: defaultSource
};
}
return await this.callbacks.selectDesktopSource(orderedSources, { includeSystemAudio });
}
private sortElectronDesktopSources(sources: DesktopSource[]): DesktopSource[] {
return [...sources].sort((left, right) => {
const weightDiff = this.getElectronDesktopSourceWeight(left) - this.getElectronDesktopSourceWeight(right);
if (weightDiff !== 0) {
return weightDiff;
}
return left.name.localeCompare(right.name);
});
}
private getElectronDesktopSourceWeight(source: DesktopSource): number {
return source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME || source.id.startsWith('screen')
? 0
: 1;
}
private isScreenShareSelectionAborted(error: unknown): boolean { private isScreenShareSelectionAborted(error: unknown): boolean {
return error instanceof Error return error instanceof Error
&& (error.name === 'AbortError' || error.name === 'NotAllowedError'); && (error.name === 'AbortError' || error.name === 'NotAllowedError');
@@ -742,425 +519,6 @@ export class ScreenShareManager {
} }
} }
private isLinuxElectronAudioRoutingSupported(): boolean {
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
return false;
}
const electronApi = this.getElectronApi();
const platformHint = `${navigator.userAgent} ${navigator.platform}`;
return !!electronApi?.prepareLinuxScreenShareAudioRouting
&& !!electronApi?.activateLinuxScreenShareAudioRouting
&& !!electronApi?.deactivateLinuxScreenShareAudioRouting
&& !!electronApi?.startLinuxScreenShareMonitorCapture
&& !!electronApi?.stopLinuxScreenShareMonitorCapture
&& !!electronApi?.onLinuxScreenShareMonitorAudioChunk
&& !!electronApi?.onLinuxScreenShareMonitorAudioEnded
&& /linux/i.test(platformHint);
}
private async startWithLinuxElectronAudioRouting(
options: ScreenShareStartOptions,
preset: ScreenShareQualityPreset
): Promise<MediaStream> {
const electronApi = this.getRequiredLinuxElectronApi();
const routingInfo = await electronApi.prepareLinuxScreenShareAudioRouting();
this.assertLinuxAudioRoutingReady(routingInfo, 'Linux Electron audio routing is unavailable.');
let desktopStream: MediaStream | null = null;
try {
const activation = await electronApi.activateLinuxScreenShareAudioRouting();
this.assertLinuxAudioRoutingReady(activation, 'Failed to activate Linux Electron audio routing.');
if (!activation.active) {
throw new Error(activation.reason || 'Failed to activate Linux Electron audio routing.');
}
desktopStream = await this.startWithDisplayMedia({
...options,
includeSystemAudio: false
}, preset);
const { audioTrack, captureInfo } = await this.startLinuxScreenShareMonitorTrack();
const stream = new MediaStream([...desktopStream.getVideoTracks(), audioTrack]);
desktopStream.getAudioTracks().forEach((track) => track.stop());
this.linuxElectronAudioRoutingActive = true;
this.logger.info('Linux Electron screen-share audio routing enabled', {
screenShareMonitorSourceName: captureInfo.sourceName,
voiceSinkName: activation.voiceSinkName
});
return stream;
} catch (error) {
desktopStream?.getTracks().forEach((track) => track.stop());
await this.resetLinuxElectronAudioRouting();
throw error;
}
}
private scheduleLinuxAudioRoutingReset(): void {
if (!this.linuxElectronAudioRoutingActive || this.linuxAudioRoutingResetPromise) {
return;
}
this.linuxAudioRoutingResetPromise = this.resetLinuxElectronAudioRouting()
.catch((error) => {
this.logger.warn('Failed to reset Linux Electron audio routing', error);
})
.finally(() => {
this.linuxAudioRoutingResetPromise = null;
});
}
private async awaitPendingLinuxAudioRoutingReset(): Promise<void> {
if (!this.linuxAudioRoutingResetPromise) {
return;
}
await this.linuxAudioRoutingResetPromise;
}
private async resetLinuxElectronAudioRouting(): Promise<void> {
const electronApi = this.getElectronApi();
const captureId = this.linuxMonitorAudioPipeline?.captureId;
this.linuxElectronAudioRoutingActive = false;
this.disposeLinuxScreenShareMonitorAudioPipeline();
try {
if (captureId && electronApi?.stopLinuxScreenShareMonitorCapture) {
await electronApi.stopLinuxScreenShareMonitorCapture(captureId);
}
} catch (error) {
this.logger.warn('Failed to stop Linux screen-share monitor capture', error);
}
try {
if (electronApi?.deactivateLinuxScreenShareAudioRouting) {
await electronApi.deactivateLinuxScreenShareAudioRouting();
}
} catch (error) {
this.logger.warn('Failed to deactivate Linux Electron audio routing', error);
}
}
private async startLinuxScreenShareMonitorTrack(): Promise<{
audioTrack: MediaStreamTrack;
captureInfo: LinuxScreenShareMonitorCaptureInfo;
}> {
const electronApi = this.getElectronApi();
if (!electronApi?.startLinuxScreenShareMonitorCapture
|| !electronApi?.stopLinuxScreenShareMonitorCapture
|| !electronApi?.onLinuxScreenShareMonitorAudioChunk
|| !electronApi?.onLinuxScreenShareMonitorAudioEnded) {
throw new Error('Linux screen-share monitor capture is unavailable.');
}
const queuedChunksByCaptureId = new Map<string, Uint8Array[]>();
const queuedEndedReasons = new Map<string, string | undefined>();
let pipeline: LinuxScreenShareMonitorAudioPipeline | null = null;
let captureInfo: LinuxScreenShareMonitorCaptureInfo | null = null;
const queueChunk = (captureId: string, chunk: Uint8Array): void => {
const queuedChunks = queuedChunksByCaptureId.get(captureId) || [];
queuedChunks.push(this.copyLinuxMonitorAudioBytes(chunk));
queuedChunksByCaptureId.set(captureId, queuedChunks);
};
const onChunk = (payload: LinuxScreenShareMonitorAudioChunkPayload): void => {
if (!pipeline || payload.captureId !== pipeline.captureId) {
queueChunk(payload.captureId, payload.chunk);
return;
}
this.handleLinuxScreenShareMonitorAudioChunk(pipeline, payload.chunk);
};
const onEnded = (payload: LinuxScreenShareMonitorAudioEndedPayload): void => {
if (!pipeline || payload.captureId !== pipeline.captureId) {
queuedEndedReasons.set(payload.captureId, payload.reason);
return;
}
this.logger.warn('Linux screen-share monitor capture ended', payload);
if (this.isScreenActive && this.linuxMonitorAudioPipeline?.captureId === payload.captureId) {
this.stopScreenShare();
}
};
const unsubscribeChunk = electronApi.onLinuxScreenShareMonitorAudioChunk(onChunk) as () => void;
const unsubscribeEnded = electronApi.onLinuxScreenShareMonitorAudioEnded(onEnded) as () => void;
try {
captureInfo = await electronApi.startLinuxScreenShareMonitorCapture() as LinuxScreenShareMonitorCaptureInfo;
const audioContext = new AudioContext({ sampleRate: captureInfo.sampleRate });
const mediaDestination = audioContext.createMediaStreamDestination();
await audioContext.resume();
const audioTrack = mediaDestination.stream.getAudioTracks()[0];
if (!audioTrack) {
throw new Error('Renderer audio pipeline did not produce a screen-share monitor track.');
}
pipeline = {
audioContext,
audioTrack,
bitsPerSample: captureInfo.bitsPerSample,
captureId: captureInfo.captureId,
channelCount: captureInfo.channelCount,
mediaDestination,
nextStartTime: audioContext.currentTime + 0.05,
pendingBytes: new Uint8Array(0),
sampleRate: captureInfo.sampleRate,
unsubscribeChunk,
unsubscribeEnded
};
this.linuxMonitorAudioPipeline = pipeline;
const activeCaptureId = captureInfo.captureId;
audioTrack.addEventListener('ended', () => {
if (this.isScreenActive && this.linuxMonitorAudioPipeline?.captureId === activeCaptureId) {
this.stopScreenShare();
}
}, { once: true });
const queuedChunks = queuedChunksByCaptureId.get(captureInfo.captureId) || [];
const activePipeline = pipeline;
queuedChunks.forEach((chunk) => {
this.handleLinuxScreenShareMonitorAudioChunk(activePipeline, chunk);
});
queuedChunksByCaptureId.delete(captureInfo.captureId);
if (queuedEndedReasons.has(captureInfo.captureId)) {
throw new Error(queuedEndedReasons.get(captureInfo.captureId)
|| 'Linux screen-share monitor capture ended before audio initialisation completed.');
}
return {
audioTrack,
captureInfo
};
} catch (error) {
if (pipeline) {
this.disposeLinuxScreenShareMonitorAudioPipeline(pipeline.captureId);
} else {
unsubscribeChunk();
unsubscribeEnded();
}
try {
await electronApi.stopLinuxScreenShareMonitorCapture(captureInfo?.captureId);
} catch (stopError) {
this.logger.warn('Failed to stop Linux screen-share monitor capture after startup failure', stopError);
}
throw error;
}
}
private disposeLinuxScreenShareMonitorAudioPipeline(captureId?: string): void {
if (!this.linuxMonitorAudioPipeline) {
return;
}
if (captureId && captureId !== this.linuxMonitorAudioPipeline.captureId) {
return;
}
const pipeline = this.linuxMonitorAudioPipeline;
this.linuxMonitorAudioPipeline = null;
pipeline.unsubscribeChunk();
pipeline.unsubscribeEnded();
pipeline.audioTrack.stop();
pipeline.pendingBytes = new Uint8Array(0);
void pipeline.audioContext.close().catch((error) => {
this.logger.warn('Failed to close Linux screen-share monitor audio context', error);
});
}
private handleLinuxScreenShareMonitorAudioChunk(
pipeline: LinuxScreenShareMonitorAudioPipeline,
chunk: Uint8Array
): void {
if (pipeline.bitsPerSample !== 16) {
this.logger.warn('Unsupported Linux screen-share monitor capture sample size', {
bitsPerSample: pipeline.bitsPerSample,
captureId: pipeline.captureId
});
return;
}
const bytesPerSample = pipeline.bitsPerSample / 8;
const bytesPerFrame = bytesPerSample * pipeline.channelCount;
if (!Number.isFinite(bytesPerFrame) || bytesPerFrame <= 0) {
return;
}
const combinedBytes = this.concatLinuxMonitorAudioBytes(pipeline.pendingBytes, chunk);
const completeByteLength = combinedBytes.byteLength - (combinedBytes.byteLength % bytesPerFrame);
if (completeByteLength <= 0) {
pipeline.pendingBytes = combinedBytes;
return;
}
const completeBytes = combinedBytes.subarray(0, completeByteLength);
pipeline.pendingBytes = this.copyLinuxMonitorAudioBytes(combinedBytes.subarray(completeByteLength));
if (pipeline.audioContext.state !== 'running') {
void pipeline.audioContext.resume().catch((error) => {
this.logger.warn('Failed to resume Linux screen-share monitor audio context', error);
});
}
const frameCount = completeByteLength / bytesPerFrame;
const audioBuffer = this.createLinuxScreenShareAudioBuffer(pipeline, completeBytes, frameCount);
const source = pipeline.audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(pipeline.mediaDestination);
source.onended = () => {
source.disconnect();
};
const now = pipeline.audioContext.currentTime;
const startTime = Math.max(pipeline.nextStartTime, now + 0.02);
source.start(startTime);
pipeline.nextStartTime = startTime + audioBuffer.duration;
}
private createLinuxScreenShareAudioBuffer(
pipeline: LinuxScreenShareMonitorAudioPipeline,
bytes: Uint8Array,
frameCount: number
): AudioBuffer {
const audioBuffer = pipeline.audioContext.createBuffer(pipeline.channelCount, frameCount, pipeline.sampleRate);
const sampleData = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const channelData = Array.from({ length: pipeline.channelCount }, (_, channelIndex) => audioBuffer.getChannelData(channelIndex));
const bytesPerSample = pipeline.bitsPerSample / 8;
const bytesPerFrame = bytesPerSample * pipeline.channelCount;
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
const frameOffset = frameIndex * bytesPerFrame;
for (let channelIndex = 0; channelIndex < pipeline.channelCount; channelIndex += 1) {
const sampleOffset = frameOffset + (channelIndex * bytesPerSample);
channelData[channelIndex][frameIndex] = sampleData.getInt16(sampleOffset, true) / 32768;
}
}
return audioBuffer;
}
private concatLinuxMonitorAudioBytes(first: Uint8Array, second: Uint8Array): Uint8Array {
if (first.byteLength === 0) {
return this.copyLinuxMonitorAudioBytes(second);
}
if (second.byteLength === 0) {
return this.copyLinuxMonitorAudioBytes(first);
}
const combined = new Uint8Array(first.byteLength + second.byteLength);
combined.set(first, 0);
combined.set(second, first.byteLength);
return combined;
}
private copyLinuxMonitorAudioBytes(bytes: Uint8Array): Uint8Array {
return bytes.byteLength > 0 ? new Uint8Array(bytes) : new Uint8Array(0);
}
private buildDisplayMediaConstraints(
options: ScreenShareStartOptions,
preset: ScreenShareQualityPreset
): DisplayMediaStreamOptions {
const supportedConstraints = navigator.mediaDevices?.getSupportedConstraints?.() as Record<string, boolean> | undefined;
const audioConstraints: Record<string, unknown> | false = options.includeSystemAudio
? {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false
}
: false;
if (audioConstraints && supportedConstraints?.['restrictOwnAudio']) {
audioConstraints['restrictOwnAudio'] = true;
}
if (audioConstraints && supportedConstraints?.['suppressLocalAudioPlayback']) {
audioConstraints['suppressLocalAudioPlayback'] = true;
}
return {
video: {
width: { ideal: preset.width, max: preset.width },
height: { ideal: preset.height, max: preset.height },
frameRate: { ideal: preset.frameRate, max: preset.frameRate }
},
audio: audioConstraints,
monitorTypeSurfaces: 'include',
selfBrowserSurface: 'exclude',
surfaceSwitching: 'include',
systemAudio: options.includeSystemAudio ? 'include' : 'exclude'
} as DisplayMediaStreamOptions;
}
private buildElectronDesktopConstraints(
sourceId: string,
options: ScreenShareStartOptions,
preset: ScreenShareQualityPreset
): ElectronDesktopMediaStreamConstraints {
const electronConstraints: ElectronDesktopMediaStreamConstraints = {
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: sourceId,
maxWidth: preset.width,
maxHeight: preset.height,
maxFrameRate: preset.frameRate
}
}
};
if (options.includeSystemAudio) {
electronConstraints.audio = {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: sourceId
}
};
} else {
electronConstraints.audio = false;
}
return electronConstraints;
}
private configureScreenStream(preset: ScreenShareQualityPreset): void { private configureScreenStream(preset: ScreenShareQualityPreset): void {
const screenVideoTrack = this.activeScreenStream?.getVideoTracks()[0]; const screenVideoTrack = this.activeScreenStream?.getVideoTracks()[0];

View File

@@ -20,6 +20,10 @@ export interface PeerData {
screenVideoSender?: RTCRtpSender; screenVideoSender?: RTCRtpSender;
/** The RTP sender carrying the screen-share audio track. */ /** The RTP sender carrying the screen-share audio track. */
screenAudioSender?: RTCRtpSender; screenAudioSender?: RTCRtpSender;
/** Known remote stream ids that carry the peer's voice audio. */
remoteVoiceStreamIds: Set<string>;
/** Known remote stream ids that carry the peer's screen-share audio/video. */
remoteScreenShareStreamIds: Set<string>;
} }
/** Credentials cached for automatic re-identification after reconnect. */ /** Credentials cached for automatic re-identification after reconnect. */

View File

@@ -107,7 +107,7 @@ export class ChatMessagesComponent {
handleTypingStarted(): void { handleTypingStarted(): void {
try { try {
this.webrtc.sendRawMessage({ type: 'typing' }); this.webrtc.sendRawMessage({ type: 'typing', serverId: this.webrtc.currentServerId });
} catch { } catch {
/* ignore */ /* ignore */
} }

View File

@@ -18,11 +18,14 @@ export interface PlaybackOptions {
* *
* Chrome/Electron workaround: a muted HTMLAudioElement is attached to * Chrome/Electron workaround: a muted HTMLAudioElement is attached to
* the stream first so that `createMediaStreamSource` actually outputs * the stream first so that `createMediaStreamSource` actually outputs
* audio. The element itself is silent - all audible output comes from * audio. The priming element itself is silent; audible output is routed
* the GainNode -> AudioContext.destination path. * through a separate output element fed by
* `GainNode -> MediaStreamDestination` so output-device switching stays
* reliable during Linux screen sharing.
*/ */
interface PeerAudioPipeline { interface PeerAudioPipeline {
audioElement: HTMLAudioElement; audioElement: HTMLAudioElement;
outputElement: HTMLAudioElement;
context: AudioContext; context: AudioContext;
sourceNodes: MediaStreamAudioSourceNode[]; sourceNodes: MediaStreamAudioSourceNode[];
gainNode: GainNode; gainNode: GainNode;
@@ -38,6 +41,7 @@ export class VoicePlaybackService {
private userVolumes = new Map<string, number>(); private userVolumes = new Map<string, number>();
private userMuted = new Map<string, boolean>(); private userMuted = new Map<string, boolean>();
private preferredOutputDeviceId = 'default'; private preferredOutputDeviceId = 'default';
private temporaryOutputDeviceId: string | null = null;
private masterVolume = 1; private masterVolume = 1;
private deafened = false; private deafened = false;
private captureEchoSuppressed = false; private captureEchoSuppressed = false;
@@ -49,6 +53,13 @@ export class VoicePlaybackService {
this.captureEchoSuppressed = this.webrtc.isScreenShareRemotePlaybackSuppressed(); this.captureEchoSuppressed = this.webrtc.isScreenShareRemotePlaybackSuppressed();
this.recalcAllGains(); this.recalcAllGains();
}); });
effect(() => {
this.temporaryOutputDeviceId = this.webrtc.forceDefaultRemotePlaybackOutput()
? 'default'
: null;
void this.applyEffectiveOutputDeviceToAllPipelines();
});
} }
handleRemoteStream(peerId: string, stream: MediaStream, options: PlaybackOptions): void { handleRemoteStream(peerId: string, stream: MediaStream, options: PlaybackOptions): void {
@@ -154,11 +165,12 @@ export class VoicePlaybackService {
* ↓ * ↓
* muted <audio> element (Chrome workaround - primes the stream) * muted <audio> element (Chrome workaround - primes the stream)
* ↓ * ↓
* MediaStreamSource → GainNode → AudioContext.destination * MediaStreamSource → GainNode → MediaStreamDestination → output <audio>
*/ */
private createPipeline(peerId: string, stream: MediaStream): void { private createPipeline(peerId: string, stream: MediaStream): void {
// Chromium/Electron needs a muted <audio> element before Web Audio can read the stream. // Chromium/Electron needs a muted <audio> element before Web Audio can read the stream.
const audioEl = new Audio(); const audioEl = new Audio();
const outputEl = new Audio();
const audioTracks = stream.getAudioTracks().filter((track) => track.readyState === 'live'); const audioTracks = stream.getAudioTracks().filter((track) => track.readyState === 'live');
audioEl.srcObject = stream; audioEl.srcObject = stream;
@@ -167,12 +179,24 @@ export class VoicePlaybackService {
const ctx = new AudioContext(); const ctx = new AudioContext();
const gainNode = ctx.createGain(); const gainNode = ctx.createGain();
const mediaDestination = ctx.createMediaStreamDestination();
const sourceNodes = audioTracks.map((track) => ctx.createMediaStreamSource(new MediaStream([track]))); const sourceNodes = audioTracks.map((track) => ctx.createMediaStreamSource(new MediaStream([track])));
sourceNodes.forEach((sourceNode) => sourceNode.connect(gainNode)); sourceNodes.forEach((sourceNode) => sourceNode.connect(gainNode));
gainNode.connect(ctx.destination); gainNode.connect(mediaDestination);
const pipeline: PeerAudioPipeline = { audioElement: audioEl, context: ctx, sourceNodes, gainNode }; outputEl.srcObject = mediaDestination.stream;
outputEl.muted = false;
outputEl.volume = 1;
outputEl.play().catch(() => {});
const pipeline: PeerAudioPipeline = {
audioElement: audioEl,
outputElement: outputEl,
context: ctx,
sourceNodes,
gainNode
};
this.peerPipelines.set(peerId, pipeline); this.peerPipelines.set(peerId, pipeline);
@@ -194,26 +218,20 @@ export class VoicePlaybackService {
} }
// eslint-disable-next-line // eslint-disable-next-line
const anyAudio = pipeline.audioElement as any; const anyAudio = pipeline.outputElement as any;
// eslint-disable-next-line
const anyCtx = pipeline.context as any;
const tasks: Promise<unknown>[] = []; const tasks: Promise<unknown>[] = [];
if (typeof anyAudio.setSinkId === 'function') { if (typeof anyAudio.setSinkId === 'function') {
tasks.push(anyAudio.setSinkId(deviceId).catch(() => undefined)); tasks.push(anyAudio.setSinkId(deviceId).catch(() => undefined));
} }
if (typeof anyCtx.setSinkId === 'function') {
tasks.push(anyCtx.setSinkId(deviceId).catch(() => undefined));
}
if (tasks.length > 0) { if (tasks.length > 0) {
await Promise.all(tasks); await Promise.all(tasks);
} }
} }
private getEffectiveOutputDeviceId(): string { private getEffectiveOutputDeviceId(): string {
return this.preferredOutputDeviceId; return this.temporaryOutputDeviceId ?? this.preferredOutputDeviceId;
} }
private removePipeline(peerId: string): void { private removePipeline(peerId: string): void {
@@ -238,6 +256,8 @@ export class VoicePlaybackService {
pipeline.audioElement.srcObject = null; pipeline.audioElement.srcObject = null;
pipeline.audioElement.remove(); pipeline.audioElement.remove();
pipeline.outputElement.srcObject = null;
pipeline.outputElement.remove();
if (pipeline.context.state !== 'closed') { if (pipeline.context.state !== 'closed') {
pipeline.context.close().catch(() => {}); pipeline.context.close().catch(() => {});

View File

@@ -42,6 +42,65 @@
{{ autoScroll() ? 'Pause auto-scroll' : 'Resume auto-scroll' }} {{ autoScroll() ? 'Pause auto-scroll' : 'Resume auto-scroll' }}
</button> </button>
<!-- Export dropdown -->
<div
class="relative"
data-export-menu
>
<button
type="button"
(click)="toggleExportMenu()"
class="inline-flex items-center gap-1.5 rounded-lg bg-secondary px-2.5 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
[attr.aria-expanded]="exportMenuOpen()"
aria-haspopup="true"
title="Export logs"
>
<ng-icon
name="lucideDownload"
class="h-3.5 w-3.5"
/>
Export
</button>
@if (exportMenuOpen()) {
<div class="absolute right-0 top-full z-10 mt-1 min-w-[11rem] rounded-lg border border-border bg-card p-1 shadow-xl">
@if (activeTab() === 'logs') {
<p class="px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Logs</p>
<button
type="button"
(click)="exportLogs('csv')"
class="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary"
>
Export as CSV
</button>
<button
type="button"
(click)="exportLogs('txt')"
class="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary"
>
Export as TXT
</button>
} @else {
<p class="px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Network</p>
<button
type="button"
(click)="exportNetwork('csv')"
class="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary"
>
Export as CSV
</button>
<button
type="button"
(click)="exportNetwork('txt')"
class="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary"
>
Export as TXT
</button>
}
</div>
}
</div>
<button <button
type="button" type="button"
(click)="clear()" (click)="clear()"

View File

@@ -1,11 +1,14 @@
import { import {
Component, Component,
HostListener,
input, input,
output output,
signal
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { import {
lucideDownload,
lucideFilter, lucideFilter,
lucidePause, lucidePause,
lucidePlay, lucidePlay,
@@ -15,6 +18,7 @@ import {
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
type DebugLogLevel = 'event' | 'info' | 'warn' | 'error' | 'debug'; type DebugLogLevel = 'event' | 'info' | 'warn' | 'error' | 'debug';
type DebugExportFormat = 'csv' | 'txt';
interface DebugNetworkSummary { interface DebugNetworkSummary {
clientCount: number; clientCount: number;
@@ -34,6 +38,7 @@ interface DebugNetworkSummary {
imports: [CommonModule, NgIcon], imports: [CommonModule, NgIcon],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideDownload,
lucideFilter, lucideFilter,
lucidePause, lucidePause,
lucidePlay, lucidePlay,
@@ -64,6 +69,10 @@ export class DebugConsoleToolbarComponent {
readonly autoScrollToggled = output<undefined>(); readonly autoScrollToggled = output<undefined>();
readonly clearRequested = output<undefined>(); readonly clearRequested = output<undefined>();
readonly closeRequested = output<undefined>(); readonly closeRequested = output<undefined>();
readonly exportLogsRequested = output<DebugExportFormat>();
readonly exportNetworkRequested = output<DebugExportFormat>();
readonly exportMenuOpen = signal(false);
readonly levels: DebugLogLevel[] = [ readonly levels: DebugLogLevel[] = [
'event', 'event',
@@ -111,6 +120,35 @@ export class DebugConsoleToolbarComponent {
this.closeRequested.emit(undefined); this.closeRequested.emit(undefined);
} }
toggleExportMenu(): void {
this.exportMenuOpen.update((open) => !open);
}
closeExportMenu(): void {
this.exportMenuOpen.set(false);
}
exportLogs(format: DebugExportFormat): void {
this.exportLogsRequested.emit(format);
this.closeExportMenu();
}
exportNetwork(format: DebugExportFormat): void {
this.exportNetworkRequested.emit(format);
this.closeExportMenu();
}
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent): void {
if (!this.exportMenuOpen())
return;
const target = event.target as HTMLElement;
if (!target.closest('[data-export-menu]'))
this.closeExportMenu();
}
getDetachLabel(): string { getDetachLabel(): string {
return this.detached() ? 'Dock' : 'Detach'; return this.detached() ? 'Dock' : 'Detach';
} }

View File

@@ -102,10 +102,11 @@
[style.left.px]="detached() ? panelLeft() : null" [style.left.px]="detached() ? panelLeft() : null"
[style.top.px]="detached() ? panelTop() : null" [style.top.px]="detached() ? panelTop() : null"
> >
<!-- Left resize bar -->
<button <button
type="button" type="button"
class="group absolute inset-y-0 left-0 z-[1] w-3 cursor-col-resize bg-transparent" class="group absolute inset-y-0 left-0 z-[1] w-3 cursor-col-resize bg-transparent"
(mousedown)="startWidthResize($event)" (mousedown)="startLeftResize($event)"
aria-label="Resize debug console width" aria-label="Resize debug console width"
> >
<span <span
@@ -113,10 +114,23 @@
></span> ></span>
</button> </button>
<!-- Right resize bar -->
<button
type="button"
class="group absolute inset-y-0 right-0 z-[1] w-3 cursor-col-resize bg-transparent"
(mousedown)="startRightResize($event)"
aria-label="Resize debug console width from right"
>
<span
class="absolute left-1/2 top-1/2 h-20 w-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-border/60 transition-colors group-hover:bg-primary/60"
></span>
</button>
<!-- Top resize bar -->
<button <button
type="button" type="button"
class="group relative h-3 w-full cursor-row-resize bg-transparent" class="group relative h-3 w-full cursor-row-resize bg-transparent"
(mousedown)="startResize($event)" (mousedown)="startTopResize($event)"
aria-label="Resize debug console" aria-label="Resize debug console"
> >
<span <span
@@ -154,6 +168,8 @@
(autoScrollToggled)="toggleAutoScroll()" (autoScrollToggled)="toggleAutoScroll()"
(clearRequested)="clearLogs()" (clearRequested)="clearLogs()"
(closeRequested)="closeConsole()" (closeRequested)="closeConsole()"
(exportLogsRequested)="exportLogs($event)"
(exportNetworkRequested)="exportNetwork($event)"
/> />
@if (activeTab() === 'logs') { @if (activeTab() === 'logs') {
@@ -168,6 +184,48 @@
[snapshot]="networkSnapshot()" [snapshot]="networkSnapshot()"
/> />
} }
<!-- Bottom resize bar -->
<button
type="button"
class="group relative h-3 w-full cursor-row-resize bg-transparent"
(mousedown)="startBottomResize($event)"
aria-label="Resize debug console height from bottom"
>
<span
class="absolute left-1/2 top-1/2 h-1 w-16 -translate-x-1/2 -translate-y-1/2 rounded-full bg-border transition-colors group-hover:bg-primary/50"
></span>
</button>
<!-- Bottom-right corner drag handle -->
<button
type="button"
class="group absolute bottom-0 right-0 z-[2] flex h-5 w-5 cursor-nwse-resize items-center justify-center bg-transparent"
(mousedown)="startCornerResize($event)"
aria-label="Resize debug console from corner"
>
<svg
class="h-3 w-3 text-border/80 transition-colors group-hover:text-primary/70"
viewBox="0 0 10 10"
fill="currentColor"
>
<circle
cx="8"
cy="8"
r="1.2"
/>
<circle
cx="4"
cy="8"
r="1.2"
/>
<circle
cx="8"
cy="4"
r="1.2"
/>
</svg>
</button>
</section> </section>
</div> </div>
} }

View File

@@ -15,6 +15,9 @@ import { DebuggingService, type DebugLogLevel } from '../../../core/services/deb
import { DebugConsoleEntryListComponent } from './debug-console-entry-list/debug-console-entry-list.component'; import { DebugConsoleEntryListComponent } from './debug-console-entry-list/debug-console-entry-list.component';
import { DebugConsoleNetworkMapComponent } from './debug-console-network-map/debug-console-network-map.component'; import { DebugConsoleNetworkMapComponent } from './debug-console-network-map/debug-console-network-map.component';
import { DebugConsoleToolbarComponent } from './debug-console-toolbar/debug-console-toolbar.component'; import { DebugConsoleToolbarComponent } from './debug-console-toolbar/debug-console-toolbar.component';
import { DebugConsoleResizeService } from './services/debug-console-resize.service';
import { DebugConsoleExportService, type DebugExportFormat } from './services/debug-console-export.service';
import { DebugConsoleEnvironmentService } from './services/debug-console-environment.service';
type DebugLevelState = Record<DebugLogLevel, boolean>; type DebugLevelState = Record<DebugLogLevel, boolean>;
@@ -44,6 +47,9 @@ type DebugConsoleLauncherVariant = 'floating' | 'inline' | 'compact';
}) })
export class DebugConsoleComponent { export class DebugConsoleComponent {
readonly debugging = inject(DebuggingService); readonly debugging = inject(DebuggingService);
readonly resizeService = inject(DebugConsoleResizeService);
readonly exportService = inject(DebugConsoleExportService);
readonly envService = inject(DebugConsoleEnvironmentService);
readonly entries = this.debugging.entries; readonly entries = this.debugging.entries;
readonly isOpen = this.debugging.isConsoleOpen; readonly isOpen = this.debugging.isConsoleOpen;
readonly networkSnapshot = this.debugging.networkSnapshot; readonly networkSnapshot = this.debugging.networkSnapshot;
@@ -56,10 +62,10 @@ export class DebugConsoleComponent {
readonly searchTerm = signal(''); readonly searchTerm = signal('');
readonly selectedSource = signal('all'); readonly selectedSource = signal('all');
readonly autoScroll = signal(true); readonly autoScroll = signal(true);
readonly panelHeight = signal(360); readonly panelHeight = this.resizeService.panelHeight;
readonly panelWidth = signal(832); readonly panelWidth = this.resizeService.panelWidth;
readonly panelLeft = signal(0); readonly panelLeft = this.resizeService.panelLeft;
readonly panelTop = signal(0); readonly panelTop = this.resizeService.panelTop;
readonly levelState = signal<DebugLevelState>({ readonly levelState = signal<DebugLevelState>({
event: true, event: true,
info: true, info: true,
@@ -123,18 +129,8 @@ export class DebugConsoleComponent {
readonly hasErrors = computed(() => this.levelCounts().error > 0); readonly hasErrors = computed(() => this.levelCounts().error > 0);
readonly networkSummary = computed(() => this.networkSnapshot().summary); readonly networkSummary = computed(() => this.networkSnapshot().summary);
private dragging = false;
private resizingHeight = false;
private resizingWidth = false;
private resizeOriginY = 0;
private resizeOriginX = 0;
private resizeOriginHeight = 360;
private resizeOriginWidth = 832;
private panelOriginLeft = 0;
private panelOriginTop = 0;
constructor() { constructor() {
this.syncPanelBounds(); this.resizeService.syncBounds(this.detached());
effect(() => { effect(() => {
const selectedSource = this.selectedSource(); const selectedSource = this.selectedSource();
@@ -147,32 +143,17 @@ export class DebugConsoleComponent {
@HostListener('window:mousemove', ['$event']) @HostListener('window:mousemove', ['$event'])
onResizeMove(event: MouseEvent): void { onResizeMove(event: MouseEvent): void {
if (this.dragging) { this.resizeService.onMouseMove(event, this.detached());
this.updateDetachedPosition(event);
return;
}
if (this.resizingWidth) {
this.updatePanelWidth(event);
return;
}
if (!this.resizingHeight)
return;
this.updatePanelHeight(event);
} }
@HostListener('window:mouseup') @HostListener('window:mouseup')
onResizeEnd(): void { onResizeEnd(): void {
this.dragging = false; this.resizeService.onMouseUp();
this.resizingHeight = false;
this.resizingWidth = false;
} }
@HostListener('window:resize') @HostListener('window:resize')
onWindowResize(): void { onWindowResize(): void {
this.syncPanelBounds(); this.resizeService.syncBounds(this.detached());
} }
toggleConsole(): void { toggleConsole(): void {
@@ -195,14 +176,38 @@ export class DebugConsoleComponent {
this.activeTab.set(tab); this.activeTab.set(tab);
} }
exportLogs(format: DebugExportFormat): void {
const env = this.envService.getEnvironment();
const name = this.envService.getFilenameSafeDisplayName();
this.exportService.exportLogs(
this.filteredEntries(),
format,
env,
name
);
}
exportNetwork(format: DebugExportFormat): void {
const env = this.envService.getEnvironment();
const name = this.envService.getFilenameSafeDisplayName();
this.exportService.exportNetwork(
this.networkSnapshot(),
format,
env,
name
);
}
toggleDetached(): void { toggleDetached(): void {
const nextDetached = !this.detached(); const nextDetached = !this.detached();
this.detached.set(nextDetached); this.detached.set(nextDetached);
this.syncPanelBounds(); this.resizeService.syncBounds(nextDetached);
if (nextDetached) if (nextDetached)
this.initializeDetachedPosition(); this.resizeService.initializeDetachedPosition();
} }
toggleLevel(level: DebugLogLevel): void { toggleLevel(level: DebugLogLevel): void {
@@ -220,35 +225,31 @@ export class DebugConsoleComponent {
this.debugging.clear(); this.debugging.clear();
} }
startResize(event: MouseEvent): void { startTopResize(event: MouseEvent): void {
event.preventDefault(); this.resizeService.startTopResize(event);
event.stopPropagation();
this.resizingHeight = true;
this.resizeOriginY = event.clientY;
this.resizeOriginHeight = this.panelHeight();
this.panelOriginTop = this.panelTop();
} }
startWidthResize(event: MouseEvent): void { startBottomResize(event: MouseEvent): void {
event.preventDefault(); this.resizeService.startBottomResize(event);
event.stopPropagation(); }
this.resizingWidth = true;
this.resizeOriginX = event.clientX; startLeftResize(event: MouseEvent): void {
this.resizeOriginWidth = this.panelWidth(); this.resizeService.startLeftResize(event);
this.panelOriginLeft = this.panelLeft(); }
startRightResize(event: MouseEvent): void {
this.resizeService.startRightResize(event);
}
startCornerResize(event: MouseEvent): void {
this.resizeService.startCornerResize(event);
} }
startDrag(event: MouseEvent): void { startDrag(event: MouseEvent): void {
if (!this.detached()) if (!this.detached())
return; return;
event.preventDefault(); this.resizeService.startDrag(event);
event.stopPropagation();
this.dragging = true;
this.resizeOriginX = event.clientX;
this.resizeOriginY = event.clientY;
this.panelOriginLeft = this.panelLeft();
this.panelOriginTop = this.panelTop();
} }
formatBadgeCount(count: number): string { formatBadgeCount(count: number): string {
@@ -257,92 +258,4 @@ export class DebugConsoleComponent {
return count.toString(); return count.toString();
} }
private updatePanelHeight(event: MouseEvent): void {
const delta = this.resizeOriginY - event.clientY;
const nextHeight = this.clampPanelHeight(this.resizeOriginHeight + delta);
this.panelHeight.set(nextHeight);
if (!this.detached())
return;
const originBottom = this.panelOriginTop + this.resizeOriginHeight;
const maxTop = this.getMaxPanelTop(nextHeight);
this.panelTop.set(this.clampValue(originBottom - nextHeight, 16, maxTop));
}
private updatePanelWidth(event: MouseEvent): void {
const delta = this.resizeOriginX - event.clientX;
const nextWidth = this.clampPanelWidth(this.resizeOriginWidth + delta);
this.panelWidth.set(nextWidth);
if (!this.detached())
return;
const originRight = this.panelOriginLeft + this.resizeOriginWidth;
const maxLeft = this.getMaxPanelLeft(nextWidth);
this.panelLeft.set(this.clampValue(originRight - nextWidth, 16, maxLeft));
}
private updateDetachedPosition(event: MouseEvent): void {
const nextLeft = this.panelOriginLeft + (event.clientX - this.resizeOriginX);
const nextTop = this.panelOriginTop + (event.clientY - this.resizeOriginY);
this.panelLeft.set(this.clampValue(nextLeft, 16, this.getMaxPanelLeft(this.panelWidth())));
this.panelTop.set(this.clampValue(nextTop, 16, this.getMaxPanelTop(this.panelHeight())));
}
private initializeDetachedPosition(): void {
if (this.panelLeft() > 0 || this.panelTop() > 0) {
this.clampDetachedPosition();
return;
}
this.panelLeft.set(this.getMaxPanelLeft(this.panelWidth()));
this.panelTop.set(this.clampValue(window.innerHeight - this.panelHeight() - 96, 16, this.getMaxPanelTop(this.panelHeight())));
}
private clampPanelHeight(height: number): number {
const maxHeight = this.detached()
? Math.max(260, window.innerHeight - 32)
: Math.floor(window.innerHeight * 0.75);
return Math.min(Math.max(height, 260), maxHeight);
}
private clampPanelWidth(width: number): number {
const maxWidth = Math.max(360, window.innerWidth - 32);
const minWidth = Math.min(460, maxWidth);
return Math.min(Math.max(width, minWidth), maxWidth);
}
private clampDetachedPosition(): void {
this.panelLeft.set(this.clampValue(this.panelLeft(), 16, this.getMaxPanelLeft(this.panelWidth())));
this.panelTop.set(this.clampValue(this.panelTop(), 16, this.getMaxPanelTop(this.panelHeight())));
}
private getMaxPanelLeft(width: number): number {
return Math.max(16, window.innerWidth - width - 16);
}
private getMaxPanelTop(height: number): number {
return Math.max(16, window.innerHeight - height - 16);
}
private syncPanelBounds(): void {
this.panelWidth.update((width) => this.clampPanelWidth(width));
this.panelHeight.update((height) => this.clampPanelHeight(height));
if (this.detached())
this.clampDetachedPosition();
}
private clampValue(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
} }

View File

@@ -0,0 +1,214 @@
import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { PlatformService } from '../../../../core/services/platform.service';
export interface DebugExportEnvironment {
appVersion: string;
displayName: string;
displayServer: string;
gpu: string;
operatingSystem: string;
platform: string;
userAgent: string;
userId: string;
}
@Injectable({ providedIn: 'root' })
export class DebugConsoleEnvironmentService {
private readonly store = inject(Store);
private readonly platformService = inject(PlatformService);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
getEnvironment(): DebugExportEnvironment {
return {
appVersion: this.resolveAppVersion(),
displayName: this.resolveDisplayName(),
displayServer: this.resolveDisplayServer(),
gpu: this.resolveGpu(),
operatingSystem: this.resolveOperatingSystem(),
platform: this.resolvePlatform(),
userAgent: navigator.userAgent,
userId: this.currentUser()?.id ?? 'Unknown'
};
}
getFilenameSafeDisplayName(): string {
const name = this.resolveDisplayName();
const sanitized = name
.replace(/[^a-zA-Z0-9._-]/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '');
return sanitized || 'unknown';
}
private resolveDisplayName(): string {
return this.currentUser()?.displayName ?? 'Unknown';
}
private resolveAppVersion(): string {
if (!this.platformService.isElectron)
return 'web';
const electronVersion = this.readElectronVersion();
return electronVersion
? `${electronVersion} (Electron)`
: 'Electron (unknown version)';
}
private resolvePlatform(): string {
if (!this.platformService.isElectron)
return 'Browser';
const os = this.resolveOperatingSystem().toLowerCase();
if (os.includes('windows'))
return 'Windows Electron';
if (os.includes('linux'))
return 'Linux Electron';
if (os.includes('mac'))
return 'macOS Electron';
return 'Electron';
}
private resolveOperatingSystem(): string {
const ua = navigator.userAgent;
if (ua.includes('Windows NT 10.0'))
return 'Windows 10/11';
if (ua.includes('Windows NT'))
return 'Windows';
if (ua.includes('Mac OS X')) {
const match = ua.match(/Mac OS X ([\d._]+)/);
const version = match?.[1]?.replace(/_/g, '.') ?? '';
return version ? `macOS ${version}` : 'macOS';
}
if (ua.includes('Linux')) {
const parts: string[] = ['Linux'];
if (ua.includes('Ubuntu'))
parts.push('(Ubuntu)');
else if (ua.includes('Fedora'))
parts.push('(Fedora)');
else if (ua.includes('Debian'))
parts.push('(Debian)');
return parts.join(' ');
}
return navigator.platform || 'Unknown';
}
private resolveDisplayServer(): string {
if (!navigator.userAgent.includes('Linux'))
return 'N/A';
try {
const ua = navigator.userAgent.toLowerCase();
if (ua.includes('wayland'))
return 'Wayland';
if (ua.includes('x11'))
return 'X11';
const isOzone = ua.includes('ozone');
if (isOzone)
return 'Ozone (Wayland likely)';
} catch {
// Ignore
}
return this.detectDisplayServerFromEnv();
}
private detectDisplayServerFromEnv(): string {
try {
// Electron may expose env vars
const api = this.getElectronApi() as
Record<string, unknown> | null;
if (!api)
return 'Unknown (Linux)';
} catch {
// Not available
}
// Best-effort heuristic: check if WebGL context
// mentions wayland in renderer string
const gpu = this.resolveGpu().toLowerCase();
if (gpu.includes('wayland'))
return 'Wayland';
return 'Unknown (Linux)';
}
private resolveGpu(): string {
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl')
?? canvas.getContext('experimental-webgl');
if (!gl || !(gl instanceof WebGLRenderingContext))
return 'Unavailable';
const ext = gl.getExtension('WEBGL_debug_renderer_info');
if (!ext)
return 'Unavailable (no debug info)';
const vendor = gl.getParameter(ext.UNMASKED_VENDOR_WEBGL);
const renderer = gl.getParameter(
ext.UNMASKED_RENDERER_WEBGL
);
const parts: string[] = [];
if (typeof renderer === 'string' && renderer.length > 0)
parts.push(renderer);
if (typeof vendor === 'string' && vendor.length > 0)
parts.push(`(${vendor})`);
return parts.length > 0
? parts.join(' ')
: 'Unknown';
} catch {
return 'Unavailable';
}
}
private readElectronVersion(): string | null {
try {
const ua = navigator.userAgent;
const match = ua.match(/metoyou\/([\d.]+)/i)
?? ua.match(/Electron\/([\d.]+)/);
return match?.[1] ?? null;
} catch {
return null;
}
}
private getElectronApi(): Record<string, unknown> | null {
try {
const win = window as Window &
{ electronAPI?: Record<string, unknown> };
return win.electronAPI ?? null;
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,517 @@
import { Injectable } from '@angular/core';
import type {
DebugLogEntry,
DebugLogLevel,
DebugNetworkEdge,
DebugNetworkNode,
DebugNetworkSnapshot
} from '../../../../core/services/debugging.service';
import type { DebugExportEnvironment } from './debug-console-environment.service';
export type DebugExportFormat = 'csv' | 'txt';
@Injectable({ providedIn: 'root' })
export class DebugConsoleExportService {
exportLogs(
entries: readonly DebugLogEntry[],
format: DebugExportFormat,
env: DebugExportEnvironment,
filenameName: string
): void {
const content = format === 'csv'
? this.buildLogsCsv(entries, env)
: this.buildLogsTxt(entries, env);
const extension = format === 'csv' ? 'csv' : 'txt';
const mime = format === 'csv'
? 'text/csv;charset=utf-8;'
: 'text/plain;charset=utf-8;';
const filename = this.buildFilename(
'debug-logs',
filenameName,
extension
);
this.downloadFile(filename, content, mime);
}
exportNetwork(
snapshot: DebugNetworkSnapshot,
format: DebugExportFormat,
env: DebugExportEnvironment,
filenameName: string
): void {
const content = format === 'csv'
? this.buildNetworkCsv(snapshot, env)
: this.buildNetworkTxt(snapshot, env);
const extension = format === 'csv' ? 'csv' : 'txt';
const mime = format === 'csv'
? 'text/csv;charset=utf-8;'
: 'text/plain;charset=utf-8;';
const filename = this.buildFilename(
'debug-network',
filenameName,
extension
);
this.downloadFile(filename, content, mime);
}
private buildLogsCsv(
entries: readonly DebugLogEntry[],
env: DebugExportEnvironment
): string {
const meta = this.buildCsvMetaSection(env);
const header = 'Timestamp,DateTime,Level,Source,Message,Payload,Count';
const rows = entries.map((entry) =>
[
entry.timeLabel,
entry.dateTimeLabel,
entry.level,
this.escapeCsvField(entry.source),
this.escapeCsvField(entry.message),
this.escapeCsvField(entry.payloadText ?? ''),
entry.count
].join(',')
);
return [
meta,
'',
header,
...rows
].join('\n');
}
private buildLogsTxt(
entries: readonly DebugLogEntry[],
env: DebugExportEnvironment
): string {
const lines: string[] = [
`Debug Logs Export - ${new Date().toISOString()}`,
this.buildSeparator(),
...this.buildTxtEnvLines(env),
this.buildSeparator(),
`Total entries: ${entries.length}`,
this.buildSeparator()
];
for (const entry of entries) {
const prefix = this.buildLevelPrefix(entry.level);
const countSuffix = entry.count > 1 ? ` (×${entry.count})` : '';
lines.push(`[${entry.dateTimeLabel}] ${prefix} [${entry.source}] ${entry.message}${countSuffix}`);
if (entry.payloadText)
lines.push(` Payload: ${entry.payloadText}`);
}
return lines.join('\n');
}
private buildNetworkCsv(
snapshot: DebugNetworkSnapshot,
env: DebugExportEnvironment
): string {
const sections: string[] = [];
sections.push(this.buildCsvMetaSection(env));
sections.push('');
sections.push(this.buildNetworkNodesCsv(snapshot.nodes));
sections.push('');
sections.push(this.buildNetworkEdgesCsv(snapshot.edges));
sections.push('');
sections.push(this.buildNetworkConnectionsCsv(snapshot));
return sections.join('\n');
}
private buildNetworkNodesCsv(nodes: readonly DebugNetworkNode[]): string {
const headerParts = [
'NodeId',
'Kind',
'Label',
'UserId',
'Identity',
'Active',
'VoiceConnected',
'Typing',
'Speaking',
'Muted',
'Deafened',
'Streaming',
'ConnectionDrops',
'PingMs',
'TextSent',
'TextReceived',
'AudioStreams',
'VideoStreams',
'OffersSent',
'OffersReceived',
'AnswersSent',
'AnswersReceived',
'IceSent',
'IceReceived',
'DownloadFileMbps',
'DownloadAudioMbps',
'DownloadVideoMbps'
];
const header = headerParts.join(',');
const rows = nodes.map((node) =>
[
this.escapeCsvField(node.id),
node.kind,
this.escapeCsvField(node.label),
this.escapeCsvField(node.userId ?? ''),
this.escapeCsvField(node.identity ?? ''),
node.isActive,
node.isVoiceConnected,
node.isTyping,
node.isSpeaking,
node.isMuted,
node.isDeafened,
node.isStreaming,
node.connectionDrops,
node.pingMs ?? '',
node.textMessages.sent,
node.textMessages.received,
node.streams.audio,
node.streams.video,
node.handshake.offersSent,
node.handshake.offersReceived,
node.handshake.answersSent,
node.handshake.answersReceived,
node.handshake.iceSent,
node.handshake.iceReceived,
node.downloads.fileMbps ?? '',
node.downloads.audioMbps ?? '',
node.downloads.videoMbps ?? ''
].join(',')
);
return [
'# Nodes',
header,
...rows
].join('\n');
}
private buildNetworkEdgesCsv(edges: readonly DebugNetworkEdge[]): string {
const header = 'EdgeId,Kind,SourceId,TargetId,SourceLabel,TargetLabel,Active,PingMs,State,MessageTotal';
const rows = edges.map((edge) =>
[
this.escapeCsvField(edge.id),
edge.kind,
this.escapeCsvField(edge.sourceId),
this.escapeCsvField(edge.targetId),
this.escapeCsvField(edge.sourceLabel),
this.escapeCsvField(edge.targetLabel),
edge.isActive,
edge.pingMs ?? '',
this.escapeCsvField(edge.stateLabel),
edge.messageTotal
].join(',')
);
return [
'# Edges',
header,
...rows
].join('\n');
}
private buildNetworkConnectionsCsv(snapshot: DebugNetworkSnapshot): string {
const header = 'SourceNode,TargetNode,EdgeKind,Direction,Active';
const rows: string[] = [];
for (const edge of snapshot.edges) {
rows.push(
[
this.escapeCsvField(edge.sourceLabel),
this.escapeCsvField(edge.targetLabel),
edge.kind,
`${edge.sourceLabel}${edge.targetLabel}`,
edge.isActive
].join(',')
);
}
return [
'# Connections',
header,
...rows
].join('\n');
}
private buildNetworkTxt(
snapshot: DebugNetworkSnapshot,
env: DebugExportEnvironment
): string {
const lines: string[] = [];
lines.push(`Network Export - ${new Date().toISOString()}`);
lines.push(this.buildSeparator());
lines.push(...this.buildTxtEnvLines(env));
lines.push(this.buildSeparator());
lines.push('SUMMARY');
lines.push(` Clients: ${snapshot.summary.clientCount}`);
lines.push(` Servers: ${snapshot.summary.serverCount}`);
lines.push(` Signaling servers: ${snapshot.summary.signalingServerCount}`);
lines.push(` Peer connections: ${snapshot.summary.peerConnectionCount}`);
lines.push(` Memberships: ${snapshot.summary.membershipCount}`);
lines.push(` Messages: ${snapshot.summary.messageCount}`);
lines.push(` Typing: ${snapshot.summary.typingCount}`);
lines.push(` Speaking: ${snapshot.summary.speakingCount}`);
lines.push(` Streaming: ${snapshot.summary.streamingCount}`);
lines.push('');
lines.push(this.buildSeparator());
lines.push('NODES');
lines.push(this.buildSeparator());
for (const node of snapshot.nodes)
this.appendNodeTxt(lines, node);
lines.push(this.buildSeparator());
lines.push('EDGES / CONNECTIONS');
lines.push(this.buildSeparator());
for (const edge of snapshot.edges)
this.appendEdgeTxt(lines, edge);
lines.push(this.buildSeparator());
lines.push('CONNECTION MAP');
lines.push(this.buildSeparator());
this.appendConnectionMap(lines, snapshot);
return lines.join('\n');
}
private appendNodeTxt(lines: string[], node: DebugNetworkNode): void {
lines.push(` [${node.kind}] ${node.label} (${node.id})`);
if (node.userId)
lines.push(` User ID: ${node.userId}`);
if (node.identity)
lines.push(` Identity: ${node.identity}`);
const statuses: string[] = [];
if (node.isActive)
statuses.push('Active');
if (node.isVoiceConnected)
statuses.push('Voice');
if (node.isTyping)
statuses.push('Typing');
if (node.isSpeaking)
statuses.push('Speaking');
if (node.isMuted)
statuses.push('Muted');
if (node.isDeafened)
statuses.push('Deafened');
if (node.isStreaming)
statuses.push('Streaming');
if (statuses.length > 0)
lines.push(` Status: ${statuses.join(', ')}`);
if (node.pingMs !== null)
lines.push(` Ping: ${node.pingMs} ms`);
lines.push(` Connection drops: ${node.connectionDrops}`);
lines.push(` Text messages: ↑${node.textMessages.sent}${node.textMessages.received}`);
lines.push(` Streams: Audio ${node.streams.audio}, Video ${node.streams.video}`);
const handshakeLine = [
`Offers ${node.handshake.offersSent}/${node.handshake.offersReceived}`,
`Answers ${node.handshake.answersSent}/${node.handshake.answersReceived}`,
`ICE ${node.handshake.iceSent}/${node.handshake.iceReceived}`
].join(', ');
lines.push(` Handshake: ${handshakeLine}`);
if (node.downloads.fileMbps !== null || node.downloads.audioMbps !== null || node.downloads.videoMbps !== null) {
const parts = [
`File ${this.formatMbps(node.downloads.fileMbps)}`,
`Audio ${this.formatMbps(node.downloads.audioMbps)}`,
`Video ${this.formatMbps(node.downloads.videoMbps)}`
];
lines.push(` Downloads: ${parts.join(', ')}`);
}
lines.push('');
}
private appendEdgeTxt(lines: string[], edge: DebugNetworkEdge): void {
const activeLabel = edge.isActive ? 'active' : 'inactive';
lines.push(` [${edge.kind}] ${edge.sourceLabel}${edge.targetLabel} (${activeLabel})`);
if (edge.pingMs !== null)
lines.push(` Ping: ${edge.pingMs} ms`);
if (edge.stateLabel)
lines.push(` State: ${edge.stateLabel}`);
lines.push(` Total messages: ${edge.messageTotal}`);
if (edge.messageGroups.length > 0) {
lines.push(' Message groups:');
for (const group of edge.messageGroups) {
const dir = group.direction === 'outbound' ? '↑' : '↓';
lines.push(` ${dir} [${group.scope}] ${group.type} ×${group.count}`);
}
}
lines.push('');
}
private appendConnectionMap(lines: string[], snapshot: DebugNetworkSnapshot): void {
const nodeMap = new Map(snapshot.nodes.map((node) => [node.id, node]));
for (const node of snapshot.nodes) {
const outgoing = snapshot.edges.filter((edge) => edge.sourceId === node.id);
const incoming = snapshot.edges.filter((edge) => edge.targetId === node.id);
lines.push(` ${node.label} (${node.kind})`);
if (outgoing.length > 0) {
lines.push(' Outgoing:');
for (const edge of outgoing) {
const target = nodeMap.get(edge.targetId);
lines.push(`${target?.label ?? edge.targetId} [${edge.kind}] ${edge.isActive ? '●' : '○'}`);
}
}
if (incoming.length > 0) {
lines.push(' Incoming:');
for (const edge of incoming) {
const source = nodeMap.get(edge.sourceId);
lines.push(`${source?.label ?? edge.sourceId} [${edge.kind}] ${edge.isActive ? '●' : '○'}`);
}
}
if (outgoing.length === 0 && incoming.length === 0)
lines.push(' (no connections)');
lines.push('');
}
}
private buildCsvMetaSection(env: DebugExportEnvironment): string {
return [
'# Export Metadata',
'Property,Value',
`Exported By,${this.escapeCsvField(env.displayName)}`,
`User ID,${this.escapeCsvField(env.userId)}`,
`Export Date,${new Date().toISOString()}`,
`App Version,${this.escapeCsvField(env.appVersion)}`,
`Platform,${this.escapeCsvField(env.platform)}`,
`Operating System,${this.escapeCsvField(env.operatingSystem)}`,
`Display Server,${this.escapeCsvField(env.displayServer)}`,
`GPU,${this.escapeCsvField(env.gpu)}`,
`User Agent,${this.escapeCsvField(env.userAgent)}`
].join('\n');
}
private buildTxtEnvLines(
env: DebugExportEnvironment
): string[] {
return [
`Exported by: ${env.displayName}`,
`User ID: ${env.userId}`,
`App version: ${env.appVersion}`,
`Platform: ${env.platform}`,
`OS: ${env.operatingSystem}`,
`Display server: ${env.displayServer}`,
`GPU: ${env.gpu}`,
`User agent: ${env.userAgent}`
];
}
private buildFilename(
prefix: string,
userLabel: string,
extension: string
): string {
const stamp = this.buildTimestamp();
return `${prefix}_${userLabel}_${stamp}.${extension}`;
}
private escapeCsvField(value: string): string {
if (value.includes(',') || value.includes('"') || value.includes('\n'))
return `"${value.replace(/"/g, '""')}"`;
return value;
}
private buildLevelPrefix(level: DebugLogLevel): string {
switch (level) {
case 'event':
return 'EVT';
case 'info':
return 'INF';
case 'warn':
return 'WRN';
case 'error':
return 'ERR';
case 'debug':
return 'DBG';
}
}
private formatMbps(value: number | null): string {
if (value === null)
return '-';
return `${value >= 10 ? value.toFixed(1) : value.toFixed(2)} Mbps`;
}
private buildTimestamp(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
return `${year}${month}${day}-${hours}${minutes}${seconds}`;
}
private buildSeparator(): string {
return '─'.repeat(60);
}
private downloadFile(filename: string, content: string, mimeType: string): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.style.display = 'none';
document.body.appendChild(anchor);
anchor.click();
requestAnimationFrame(() => {
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
});
}
}

View File

@@ -0,0 +1,284 @@
import { Injectable, signal } from '@angular/core';
const STORAGE_KEY = 'metoyou_debug_console_layout';
const DEFAULT_HEIGHT = 520;
const DEFAULT_WIDTH = 832;
const MIN_HEIGHT = 260;
const MIN_WIDTH = 460;
interface PersistedLayout {
height: number;
width: number;
}
@Injectable({ providedIn: 'root' })
export class DebugConsoleResizeService {
readonly panelHeight = signal(DEFAULT_HEIGHT);
readonly panelWidth = signal(DEFAULT_WIDTH);
readonly panelLeft = signal(0);
readonly panelTop = signal(0);
private dragging = false;
private resizingTop = false;
private resizingBottom = false;
private resizingLeft = false;
private resizingRight = false;
private resizingCorner = false;
private resizeOriginX = 0;
private resizeOriginY = 0;
private resizeOriginHeight = DEFAULT_HEIGHT;
private resizeOriginWidth = DEFAULT_WIDTH;
private panelOriginLeft = 0;
private panelOriginTop = 0;
constructor() {
this.loadLayout();
}
get isResizing(): boolean {
return this.resizingTop || this.resizingBottom || this.resizingLeft || this.resizingRight || this.resizingCorner;
}
get isDragging(): boolean {
return this.dragging;
}
startTopResize(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
this.resizingTop = true;
this.resizeOriginY = event.clientY;
this.resizeOriginHeight = this.panelHeight();
this.panelOriginTop = this.panelTop();
}
startBottomResize(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
this.resizingBottom = true;
this.resizeOriginY = event.clientY;
this.resizeOriginHeight = this.panelHeight();
}
startLeftResize(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
this.resizingLeft = true;
this.resizeOriginX = event.clientX;
this.resizeOriginWidth = this.panelWidth();
this.panelOriginLeft = this.panelLeft();
}
startRightResize(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
this.resizingRight = true;
this.resizeOriginX = event.clientX;
this.resizeOriginWidth = this.panelWidth();
}
startCornerResize(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
this.resizingCorner = true;
this.resizeOriginX = event.clientX;
this.resizeOriginY = event.clientY;
this.resizeOriginWidth = this.panelWidth();
this.resizeOriginHeight = this.panelHeight();
}
startDrag(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
this.dragging = true;
this.resizeOriginX = event.clientX;
this.resizeOriginY = event.clientY;
this.panelOriginLeft = this.panelLeft();
this.panelOriginTop = this.panelTop();
}
onMouseMove(event: MouseEvent, detached: boolean): void {
if (this.dragging) {
this.updateDetachedPosition(event);
return;
}
if (this.resizingCorner) {
this.updateCornerResize(event, detached);
return;
}
if (this.resizingLeft) {
this.updateLeftResize(event, detached);
return;
}
if (this.resizingRight) {
this.updateRightResize(event, detached);
return;
}
if (this.resizingTop) {
this.updateTopResize(event, detached);
return;
}
if (this.resizingBottom) {
this.updateBottomResize(event, detached);
}
}
onMouseUp(): void {
const wasActive = this.isResizing || this.dragging;
this.dragging = false;
this.resizingTop = false;
this.resizingBottom = false;
this.resizingLeft = false;
this.resizingRight = false;
this.resizingCorner = false;
if (wasActive)
this.persistLayout();
}
syncBounds(detached: boolean): void {
this.panelWidth.update((width) => this.clampWidth(width, detached));
this.panelHeight.update((height) => this.clampHeight(height, detached));
if (detached)
this.clampDetachedPosition();
}
initializeDetachedPosition(): void {
if (this.panelLeft() > 0 || this.panelTop() > 0) {
this.clampDetachedPosition();
return;
}
this.panelLeft.set(this.getMaxLeft(this.panelWidth()));
this.panelTop.set(
this.clamp(window.innerHeight - this.panelHeight() - 96, 16, this.getMaxTop(this.panelHeight()))
);
}
private updateTopResize(event: MouseEvent, detached: boolean): void {
const delta = this.resizeOriginY - event.clientY;
const nextHeight = this.clampHeight(this.resizeOriginHeight + delta, detached);
this.panelHeight.set(nextHeight);
if (!detached)
return;
const originBottom = this.panelOriginTop + this.resizeOriginHeight;
this.panelTop.set(this.clamp(originBottom - nextHeight, 16, this.getMaxTop(nextHeight)));
}
private updateBottomResize(event: MouseEvent, detached: boolean): void {
const delta = event.clientY - this.resizeOriginY;
this.panelHeight.set(this.clampHeight(this.resizeOriginHeight + delta, detached));
}
private updateLeftResize(event: MouseEvent, detached: boolean): void {
const delta = this.resizeOriginX - event.clientX;
const nextWidth = this.clampWidth(this.resizeOriginWidth + delta, detached);
this.panelWidth.set(nextWidth);
if (!detached)
return;
const originRight = this.panelOriginLeft + this.resizeOriginWidth;
this.panelLeft.set(this.clamp(originRight - nextWidth, 16, this.getMaxLeft(nextWidth)));
}
private updateRightResize(event: MouseEvent, detached: boolean): void {
const delta = event.clientX - this.resizeOriginX;
this.panelWidth.set(this.clampWidth(this.resizeOriginWidth + delta, detached));
}
private updateCornerResize(event: MouseEvent, detached: boolean): void {
const deltaX = event.clientX - this.resizeOriginX;
const deltaY = event.clientY - this.resizeOriginY;
this.panelWidth.set(this.clampWidth(this.resizeOriginWidth + deltaX, detached));
this.panelHeight.set(this.clampHeight(this.resizeOriginHeight + deltaY, detached));
}
private updateDetachedPosition(event: MouseEvent): void {
const nextLeft = this.panelOriginLeft + (event.clientX - this.resizeOriginX);
const nextTop = this.panelOriginTop + (event.clientY - this.resizeOriginY);
this.panelLeft.set(this.clamp(nextLeft, 16, this.getMaxLeft(this.panelWidth())));
this.panelTop.set(this.clamp(nextTop, 16, this.getMaxTop(this.panelHeight())));
}
private clampHeight(height: number, detached?: boolean): number {
const maxHeight = detached
? Math.max(MIN_HEIGHT, window.innerHeight - 32)
: Math.floor(window.innerHeight * 0.75);
return Math.min(Math.max(height, MIN_HEIGHT), maxHeight);
}
private clampWidth(width: number, _detached?: boolean): number {
const maxWidth = Math.max(MIN_WIDTH, window.innerWidth - 32);
const minWidth = Math.min(MIN_WIDTH, maxWidth);
return Math.min(Math.max(width, minWidth), maxWidth);
}
private clampDetachedPosition(): void {
this.panelLeft.set(this.clamp(this.panelLeft(), 16, this.getMaxLeft(this.panelWidth())));
this.panelTop.set(this.clamp(this.panelTop(), 16, this.getMaxTop(this.panelHeight())));
}
private getMaxLeft(width: number): number {
return Math.max(16, window.innerWidth - width - 16);
}
private getMaxTop(height: number): number {
return Math.max(16, window.innerHeight - height - 16);
}
private clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
private loadLayout(): void {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw)
return;
const parsed = JSON.parse(raw) as PersistedLayout;
if (typeof parsed.height === 'number' && parsed.height >= MIN_HEIGHT)
this.panelHeight.set(parsed.height);
if (typeof parsed.width === 'number' && parsed.width >= MIN_WIDTH)
this.panelWidth.set(parsed.width);
} catch {
// Ignore corrupted storage
}
}
private persistLayout(): void {
try {
const layout: PersistedLayout = {
height: this.panelHeight(),
width: this.panelWidth()
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(layout));
} catch {
// Ignore storage failures
}
}
}

View File

@@ -64,7 +64,12 @@
"scripts": [], "scripts": [],
"server": "src/main.server.ts", "server": "src/main.server.ts",
"security": { "security": {
"allowedHosts": [] "allowedHosts": [
"toju.app",
"www.toju.app",
"localhost",
"127.0.0.1"
]
}, },
"prerender": true, "prerender": true,
"ssr": { "ssr": {

View File

@@ -17,6 +17,7 @@
"@angular/platform-server": "^19.2.0", "@angular/platform-server": "^19.2.0",
"@angular/router": "^19.2.0", "@angular/router": "^19.2.0",
"@angular/ssr": "^19.2.21", "@angular/ssr": "^19.2.21",
"@ngx-translate/core": "^17.0.0",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tsparticles/angular": "^3.0.0", "@tsparticles/angular": "^3.0.0",
"@tsparticles/engine": "^3.9.1", "@tsparticles/engine": "^3.9.1",
@@ -670,6 +671,87 @@
"rxjs": "^6.5.3 || ^7.4.0" "rxjs": "^6.5.3 || ^7.4.0"
} }
}, },
"node_modules/@angular/localize": {
"version": "19.2.19",
"resolved": "https://registry.npmjs.org/@angular/localize/-/localize-19.2.19.tgz",
"integrity": "sha512-FlnungTK9pNDi283j0mhuALRzgVj56vfEH//dM8/9CsNtfpGoBnKBOZl/aN//ShilAnjP1UFN40kYVRxgI1kjg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@babel/core": "7.26.9",
"@types/babel__core": "7.20.5",
"fast-glob": "3.3.3",
"yargs": "^17.2.1"
},
"bin": {
"localize-extract": "tools/bundles/src/extract/cli.js",
"localize-migrate": "tools/bundles/src/migrate/cli.js",
"localize-translate": "tools/bundles/src/translate/cli.js"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"@angular/compiler": "19.2.19",
"@angular/compiler-cli": "19.2.19"
}
},
"node_modules/@angular/localize/node_modules/@babel/core": {
"version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz",
"integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.26.2",
"@babel/generator": "^7.26.9",
"@babel/helper-compilation-targets": "^7.26.5",
"@babel/helper-module-transforms": "^7.26.0",
"@babel/helpers": "^7.26.9",
"@babel/parser": "^7.26.9",
"@babel/template": "^7.26.9",
"@babel/traverse": "^7.26.9",
"@babel/types": "^7.26.9",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
"json5": "^2.2.3",
"semver": "^6.3.1"
},
"engines": {
"node": ">=6.9.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/babel"
}
},
"node_modules/@angular/localize/node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/@angular/localize/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@angular/platform-browser": { "node_modules/@angular/platform-browser": {
"version": "19.2.19", "version": "19.2.19",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.19.tgz", "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.19.tgz",
@@ -4403,6 +4485,19 @@
"webpack": "^5.54.0" "webpack": "^5.54.0"
} }
}, },
"node_modules/@ngx-translate/core": {
"version": "17.0.0",
"resolved": "https://registry.npmjs.org/@ngx-translate/core/-/core-17.0.0.tgz",
"integrity": "sha512-Rft2D5ns2pq4orLZjEtx1uhNuEBerUdpFUG1IcqtGuipj6SavgB8SkxtNQALNDA+EVlvsNCCjC2ewZVtUeN6rg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": ">=16",
"@angular/core": ">=16"
}
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -6083,6 +6178,59 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@babel/parser": "^7.20.7",
"@babel/types": "^7.20.7",
"@types/babel__generator": "*",
"@types/babel__template": "*",
"@types/babel__traverse": "*"
}
},
"node_modules/@types/babel__generator": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@babel/types": "^7.0.0"
}
},
"node_modules/@types/babel__template": {
"version": "7.4.4",
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@babel/parser": "^7.1.0",
"@babel/types": "^7.0.0"
}
},
"node_modules/@types/babel__traverse": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",

View File

@@ -20,6 +20,7 @@
"@angular/platform-server": "^19.2.0", "@angular/platform-server": "^19.2.0",
"@angular/router": "^19.2.0", "@angular/router": "^19.2.0",
"@angular/ssr": "^19.2.21", "@angular/ssr": "^19.2.21",
"@ngx-translate/core": "^17.0.0",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tsparticles/angular": "^3.0.0", "@tsparticles/angular": "^3.0.0",
"@tsparticles/engine": "^3.9.1", "@tsparticles/engine": "^3.9.1",

353
website/public/i18n/en.json Normal file
View File

@@ -0,0 +1,353 @@
{
"common": {
"brand": "Toju",
"os": {
"windows": "Windows",
"macos": "macOS",
"linux": "Linux",
"linuxDebian": "Linux (deb)",
"archive": "Archive",
"web": "Web",
"other": "Other"
},
"actions": {
"downloadBrand": "Download Toju",
"downloadFor": "Download for {{os}}",
"openInBrowser": "Open in Browser",
"tryInBrowser": "Try in Browser",
"useWebVersion": "Use Web Version",
"openWebVersion": "Open web version",
"goToDownloads": "Go to downloads",
"learnHowItWorks": "Learn how it works",
"viewSourceCode": "View source code",
"buyUsCoffee": "Buy us a coffee"
}
},
"components": {
"header": {
"homeAriaLabel": "Toju home",
"beta": "Beta",
"navigation": {
"home": "Home",
"whatIsToju": "What is Toju?",
"downloads": "Downloads",
"philosophy": "Our Philosophy"
},
"supportUs": "Support Us",
"useWebVersion": "Use Web Version",
"toggleMenu": "Toggle menu"
},
"footer": {
"description": "Free, open-source, peer-to-peer communication. Built by people who believe privacy is a right, not a premium feature.",
"sections": {
"product": "Product",
"community": "Community",
"values": "Values"
},
"links": {
"downloads": "Downloads",
"webVersion": "Web Version",
"whatIsToju": "What is Toju?",
"imageGallery": "Image Gallery",
"sourceCode": "Source Code",
"github": "GitHub",
"supportUs": "Support Us",
"ourPhilosophy": "Our Philosophy"
},
"values": {
"freeForever": "100% Free Forever",
"openSource": "Open Source"
},
"copyright": "© {{year}} Myxelium. Toju is open-source software.",
"viewSourceOnGitea": "View source code on Gitea",
"viewProjectOnGitHub": "View the project on GitHub"
},
"adSlot": {
"label": "Advertisement"
}
},
"pages": {
"home": {
"seo": {
"title": "Free Peer-to-Peer Voice, Video & Chat",
"description": "Toju is a free, open-source, peer-to-peer communication app. Crystal-clear voice calls, unlimited screen sharing, no file size limits, complete privacy."
},
"hero": {
"badge": "Currently in Beta - Free & Open Source",
"titleLine1": "Talk freely.",
"titleLine2": "Own your voice.",
"description": "Crystal-clear voice calls, unlimited screen sharing, and file transfers with no size limits. Peer-to-peer. Private. Completely free.",
"version": "Version {{version}}",
"allPlatforms": "All platforms"
},
"features": {
"titleLine1": "Everything you need,",
"titleLine2": "nothing you don't.",
"description": "No bloat. No paywalls. Just the tools to connect with the people who matter.",
"items": {
"voiceCalls": {
"title": "HD Voice Calls",
"description": "Crystal-clear audio with built-in noise reduction. Hear every word, not the background. No quality compromises, ever."
},
"screenSharing": {
"title": "Screen Sharing",
"description": "Share your screen at full resolution. No time limits, no quality downgrades. Perfect for pair programming, presentations, or showing your epic gameplay."
},
"fileSharing": {
"title": "Unlimited File Sharing",
"description": "Send files of any size directly to your friends. No upload limits, no compression. Your files go straight from you to them."
},
"privacy": {
"title": "True Privacy",
"description": "Peer-to-peer means your data goes directly between you and your friends. No servers storing your conversations. Your business is your business."
},
"openSource": {
"title": "Open Source",
"description": "Every line of code is public. Audit it, modify it, host your own signal server. Full transparency - nothing is hidden."
},
"free": {
"title": "Completely Free",
"description": "No premium tiers. No paywalls. No \"starter plans\". Every feature is available to everyone, always. Made with love, not profit margins."
}
}
},
"gaming": {
"badge": "Built for Gamers",
"titleLine1": "Your perfect",
"titleLine2": "gaming companion.",
"description": "Ultra-low latency voice chat that doesn't eat your bandwidth. Share your screen without frame drops. Send clips and files instantly. All while keeping your CPU free for what matters - winning.",
"bullets": {
"lowLatency": "Low-latency peer-to-peer voice - no relay servers in the way",
"noiseSuppression": "AI-powered noise suppression - keyboard clatter stays out",
"screenShare": "Full-resolution screen sharing at high FPS",
"fileTransfers": "Send replays and screenshots with no file size limit"
},
"imageAlt": "Toju gaming screen sharing preview",
"caption": "Game on. No limits."
},
"selfHostable": {
"badge": "Self-Hostable",
"titleLine1": "Your infrastructure,",
"titleLine2": "your rules.",
"description": "Toju uses a lightweight coordination server to help peers find each other - that's it. Your actual conversations never touch a server. Want even more control? Run your own coordination server in minutes. Full independence, zero compromises."
},
"cta": {
"title": "Ready to take back your conversations?",
"description": "Join thousands choosing privacy, freedom, and real connection."
}
},
"downloads": {
"seo": {
"title": "Download Toju",
"description": "Download Toju for Windows, Linux, or use the web version. Free peer-to-peer voice chat, screen sharing, and file transfers."
},
"hero": {
"titlePrefix": "Download",
"description": "Available for Windows, Linux, and in your browser. Always free, always the full experience."
},
"recommended": {
"badge": "Recommended for you",
"title": "Toju for {{os}}",
"version": "Version {{version}}",
"webVersionPrefix": "Or",
"webVersionLink": "use the web version",
"webVersionSuffix": "- no download required."
},
"allPlatforms": {
"title": "All platforms",
"assetIconAlt": "{{os}} icon"
},
"previousReleases": {
"title": "Previous Releases",
"fileCount": "{{count}} files"
},
"loading": "Fetching releases...",
"rss": {
"prefix": "Stay updated with our",
"link": "RSS feed"
}
},
"gallery": {
"seo": {
"title": "Toju Image Gallery",
"description": "Browse screenshots of Toju and explore the interface for chat, file sharing, voice, and screen sharing."
},
"hero": {
"badge": "Image Gallery",
"titlePrefix": "A closer look at",
"description": "Explore screenshots of the app experience, from voice chat and media sharing to servers, rooms, and full-screen collaboration."
},
"featured": {
"imageAlt": "Toju main application screenshot",
"label": "Featured",
"title": "The full Toju workspace",
"description": "See the main interface where rooms, messages, presence, and media all come together in one focused layout."
},
"items": {
"mainChatView": {
"title": "Main chat view",
"description": "The core Toju experience with channels, messages, and direct communication tools."
},
"gamingScreenShare": {
"title": "Gaming screen share",
"description": "Share gameplay, guides, and live moments with smooth full-resolution screen sharing."
},
"serverOverview": {
"title": "Server overview",
"description": "Navigate servers and rooms with a layout designed for clarity and speed."
},
"musicAndVoice": {
"title": "Music and voice",
"description": "Stay in sync with voice and media features in a focused, low-friction interface."
},
"videoSharing": {
"title": "Video sharing",
"description": "Preview and share visual content directly with your friends and communities."
},
"fileTransfers": {
"title": "File transfers",
"description": "Move files quickly without artificial size limits or unnecessary hoops."
},
"richMediaChat": {
"title": "Rich media chat",
"description": "Conversations stay lively with visual media support built right in."
}
},
"cta": {
"title": "Want to see it in action?",
"description": "Download Toju or jump into the browser experience and explore the interface yourself."
}
},
"philosophy": {
"seo": {
"title": "Our Philosophy - Why We Build Toju",
"description": "Toju exists because privacy is a right, not a premium feature. No paywalls, no data harvesting, no predatory pricing. Learn why we build free, open-source communication tools."
},
"hero": {
"badge": "Our Manifesto",
"titlePrefix": "Why we",
"titleHighlight": "build",
"description": "A letter from the people behind the project."
},
"sections": {
"ownership": {
"title": "We Lost Something Important",
"paragraph1": "Over the past two decades, something fundamental shifted. Our conversations, our memories, our connections - they stopped belonging to us. They live on servers we don't control, inside apps that treat our personal lives as data to be harvested, analyzed, and sold to the highest bidder.",
"paragraph2": "We gave up ownership of our digital lives so gradually that most of us didn't even notice. A \"free\" app here, a convenient service there - each one taking a little more of our privacy in exchange for convenience. Toju exists because we believe it's time to take it back."
},
"paywalls": {
"title": "No Paywalls. No Premium Tiers. Ever.",
"paragraph1": "You know the playbook: launch a free product, build a user base, then start locking features behind subscription tiers. Can't share your screen at more than 720p unless you upgrade. File size limited to 8 MB. Want noise suppression? That's a premium feature now.",
"paragraph2": "We refuse to play that game. <strong class=\"text-foreground\">Every feature in Toju is available to every user, always.</strong> There is no \"Toju Nitro,\" no \"Pro plan,\" no artificial limitations designed to push you toward your wallet. Communication is a human need, not a luxury - and the tools for it should reflect that."
},
"privacy": {
"title": "Privacy Is a Right, Not a Feature",
"paragraph1": "Most communication platforms collect everything: who you talk to, when, for how long, what you share. They build profiles of your social graph, your habits, your interests. Even services that claim to care about privacy still store metadata on their servers.",
"paragraph2": "Toju is architecturally different. Your data goes directly from your device to your friend's device. We don't have your messages. We don't have your files. We don't have your call history. Not because we promised not to look - but because the data never touches our infrastructure. We built the technology so that <strong class=\"text-foreground\">privacy isn't something we offer; it's something we literally cannot violate.</strong>"
},
"heart": {
"title": "Built from the Heart",
"paragraph1": "Toju wasn't born in a boardroom with revenue projections. It was born from frustration - frustration with being the product, with watching friends get locked out of features they used to have for free, with the growing feeling that the tools we depend on daily don't actually serve our interests.",
"paragraph2": "We build Toju because we genuinely want to make the world a little better. The internet was supposed to connect people freely, and somewhere along the way, that mission got hijacked by business models that exploit the very connections they facilitate. <strong class=\"text-foreground\">Toju is our small act of reclaiming that original promise.</strong>"
},
"openSource": {
"title": "Transparent by Default",
"paragraph1": "Every line of Toju's code is publicly available. You can read it, audit it, contribute to it, or fork it and build your own version. This isn't a marketing decision - it's an accountability decision. When you can see exactly how the software works, you never have to take our word for anything.",
"paragraph2": "Open source also means Toju belongs to its community, not to a company. Even if we stopped development tomorrow, the project lives on. Your communication infrastructure shouldn't depend on a single organization's survival."
}
},
"promise": {
"title": "Our Promise",
"items": {
"noPaywalls": "We will <strong class=\"text-foreground\">never</strong> lock features behind a paywall.",
"noDataSales": "We will <strong class=\"text-foreground\">never</strong> sell, monetize, or harvest your data.",
"openSource": "We will <strong class=\"text-foreground\">always</strong> keep the source code open and auditable.",
"usersBeforeProfit": "We will <strong class=\"text-foreground\">always</strong> put users before profit."
},
"signature": "- The Myxelium team"
},
"support": {
"title": "Help us keep going",
"description": "If Toju's mission resonates with you, consider supporting the project. Every contribution helps us keep the lights on and development moving forward - without ever compromising our values."
}
},
"whatIsToju": {
"seo": {
"title": "What is Toju? - How It Works",
"description": "Learn how Toju's peer-to-peer technology delivers free, private, unlimited voice calls, screen sharing, and file transfers without centralized servers."
},
"hero": {
"badge": "The Big Picture",
"titlePrefix": "What is",
"description": "Toju is a communication app that lets you voice chat, share your screen, send files, and message your friends - all without your data passing through someone else's servers. Think of it as your own private phone line that nobody can tap into."
},
"howItWorks": {
"titlePrefix": "How does it",
"titleHighlight": "work"
},
"steps": {
"one": {
"title": "You connect directly to your friends",
"description": "When you start a call or send a file on Toju, your data travels directly from your device to your friend's device. There's no company server in the middle storing your conversations, listening to your calls, or scanning your files. This is called <strong class=\"text-foreground\">peer-to-peer</strong> - it's like having a direct road between your houses instead of going through a toll booth."
},
"two": {
"title": "A tiny helper gets you connected",
"description": "The only thing a server does is help your device find your friend's device - like a mutual friend introducing you at a party. Once you're connected, the server steps out of the picture entirely. It never sees what you say, share, or send. This helper is called a <strong class=\"text-foreground\">signal server</strong>, and you can even run your own if you'd like."
},
"three": {
"title": "No limits because there are no middlemen",
"description": "Since your data doesn't pass through our servers, we don't need to pay for massive infrastructure. That's why Toju can offer <strong class=\"text-foreground\">unlimited screen sharing, file transfers of any size, and high-quality voice</strong> - all completely free. There's no business reason to limit what you can do, and we never will."
}
},
"whyDesigned": {
"titlePrefix": "Why is it",
"titleHighlight": "designed",
"titleSuffix": "this way?"
},
"benefits": {
"privacyArchitecture": {
"title": "Privacy by Architecture",
"description": "We didn't just add privacy as a feature - we built the entire app around it. When there's no central server handling your data, there's nothing to hack, subpoena, or sell. Your privacy isn't protected by a promise; it's protected by how the technology works."
},
"performance": {
"title": "Performance Without Compromise",
"description": "Direct connections mean lower latency. Your voice reaches your friend faster. Your screen share is smoother. Your file arrives in the time it actually takes to transfer - not in the time it takes to upload, store, then download."
},
"sustainable": {
"title": "Sustainable & Free",
"description": "Running a traditional chat service with millions of users costs enormous amounts of money for servers. That cost gets passed to you. With peer-to-peer, the only infrastructure we run is a tiny coordination server - costing almost nothing. That's how we keep it free permanently."
},
"independence": {
"title": "Independence & Freedom",
"description": "You're not locked into our ecosystem. The code is open source. You can run your own server. If we ever disappeared tomorrow, you could still use Toju. Your communication tools should belong to you, not a corporation."
}
},
"faq": {
"titlePrefix": "Common",
"titleHighlight": "Questions",
"items": {
"free": {
"question": "Is Toju really free? What's the catch?",
"answer": "Yes, it's really free. There is no catch. Because Toju uses peer-to-peer connections, we don't need expensive server infrastructure. Our costs are minimal, and we fund development through community support and donations. Every feature is available to everyone."
},
"technical": {
"question": "Do I need technical knowledge to use Toju?",
"answer": "Not at all. Toju works like any other chat app - download it, create an account, and start talking. All the peer-to-peer magic happens behind the scenes. You just enjoy the benefits of better privacy, performance, and no limits."
},
"selfHost": {
"question": "What does \"self-host the signal server\" mean?",
"answer": "The signal server is a tiny program that helps users find each other online. We run one by default, but if you prefer complete control, you can run your own copy on your own hardware. It's like having your own private phone directory - only people you invite can use it."
},
"safe": {
"question": "Is my data safe?",
"answer": "Your conversations, files, and calls go directly between you and the person you're talking to. They never pass through or get stored on our servers. Even if someone broke into our server, there would be nothing to find - because we never had your data in the first place."
}
}
},
"cta": {
"title": "Ready to try it?",
"description": "Available on Windows, Linux, and in your browser. Always free."
}
}
}
}

View File

@@ -1,8 +1,10 @@
import { Component } from '@angular/core'; import { Component, inject } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { HeaderComponent } from './components/header/header.component'; import { HeaderComponent } from './components/header/header.component';
import { FooterComponent } from './components/footer/footer.component'; import { FooterComponent } from './components/footer/footer.component';
import { ParticleBgComponent } from './components/particle-bg/particle-bg.component'; import { ParticleBgComponent } from './components/particle-bg/particle-bg.component';
import translationsEn from '../../public/i18n/en.json';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -16,5 +18,13 @@ import { ParticleBgComponent } from './components/particle-bg/particle-bg.compon
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.scss' styleUrl: './app.component.scss'
}) })
export class AppComponent {} export class AppComponent {
private readonly translate = inject(TranslateService);
constructor() {
this.translate.setTranslation('en', translationsEn);
this.translate.setFallbackLang('en');
this.translate.use('en');
}
}

View File

@@ -2,6 +2,7 @@ import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter, withInMemoryScrolling } from '@angular/router'; import { provideRouter, withInMemoryScrolling } from '@angular/router';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser'; import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import { provideHttpClient, withFetch } from '@angular/common/http'; import { provideHttpClient, withFetch } from '@angular/common/http';
import { provideTranslateService } from '@ngx-translate/core';
import { routes } from './app.routes'; import { routes } from './app.routes';
@@ -13,6 +14,10 @@ export const appConfig: ApplicationConfig = {
withInMemoryScrolling({ scrollPositionRestoration: 'top', anchorScrolling: 'enabled' }) withInMemoryScrolling({ scrollPositionRestoration: 'top', anchorScrolling: 'enabled' })
), ),
provideClientHydration(withEventReplay()), provideClientHydration(withEventReplay()),
provideHttpClient(withFetch()) provideHttpClient(withFetch()),
provideTranslateService({
fallbackLang: 'en',
lang: 'en'
})
] ]
}; };

View File

@@ -3,7 +3,7 @@
<div <div
class="rounded-lg border border-dashed border-border/50 bg-card/30 min-h-[90px] flex items-center justify-center text-xs text-muted-foreground/50" class="rounded-lg border border-dashed border-border/50 bg-card/30 min-h-[90px] flex items-center justify-center text-xs text-muted-foreground/50"
> >
Advertisement {{ 'components.adSlot.label' | translate }}
</div> </div>
</div> </div>
} }

View File

@@ -1,9 +1,11 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { AdService } from '../../services/ad.service'; import { AdService } from '../../services/ad.service';
@Component({ @Component({
selector: 'app-ad-slot', selector: 'app-ad-slot',
standalone: true, standalone: true,
imports: [TranslateModule],
templateUrl: './ad-slot.component.html' templateUrl: './ad-slot.component.html'
}) })
export class AdSlotComponent { export class AdSlotComponent {

View File

@@ -6,25 +6,25 @@
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-4">
<img <img
src="/images/toju-logo-transparent.png" src="/images/toju-logo-transparent.png"
alt="Toju" [attr.alt]="'common.brand' | translate"
class="h-8 w-auto object-contain" class="h-8 w-auto object-contain"
/> />
</div> </div>
<p class="text-sm text-muted-foreground leading-relaxed"> <p class="text-sm text-muted-foreground leading-relaxed">
Free, open-source, peer-to-peer communication. Built by people who believe privacy is a right, not a premium feature. {{ 'components.footer.description' | translate }}
</p> </p>
</div> </div>
<!-- Product --> <!-- Product -->
<div> <div>
<h4 class="text-sm font-semibold text-foreground mb-4 uppercase tracking-wider">Product</h4> <h4 class="text-sm font-semibold text-foreground mb-4 uppercase tracking-wider">{{ 'components.footer.sections.product' | translate }}</h4>
<ul class="space-y-3"> <ul class="space-y-3">
<li> <li>
<a <a
routerLink="/downloads" routerLink="/downloads"
class="text-sm text-muted-foreground hover:text-foreground transition-colors" class="text-sm text-muted-foreground hover:text-foreground transition-colors"
> >
Downloads {{ 'components.footer.links.downloads' | translate }}
</a> </a>
</li> </li>
<li> <li>
@@ -34,7 +34,7 @@
rel="noopener" rel="noopener"
class="text-sm text-muted-foreground hover:text-foreground transition-colors" class="text-sm text-muted-foreground hover:text-foreground transition-colors"
> >
Web Version {{ 'components.footer.links.webVersion' | translate }}
</a> </a>
</li> </li>
<li> <li>
@@ -42,7 +42,7 @@
routerLink="/what-is-toju" routerLink="/what-is-toju"
class="text-sm text-muted-foreground hover:text-foreground transition-colors" class="text-sm text-muted-foreground hover:text-foreground transition-colors"
> >
What is Toju? {{ 'components.footer.links.whatIsToju' | translate }}
</a> </a>
</li> </li>
<li> <li>
@@ -50,7 +50,7 @@
routerLink="/gallery" routerLink="/gallery"
class="text-sm text-muted-foreground hover:text-foreground transition-colors" class="text-sm text-muted-foreground hover:text-foreground transition-colors"
> >
Image Gallery {{ 'components.footer.links.imageGallery' | translate }}
</a> </a>
</li> </li>
</ul> </ul>
@@ -58,7 +58,7 @@
<!-- Community --> <!-- Community -->
<div> <div>
<h4 class="text-sm font-semibold text-foreground mb-4 uppercase tracking-wider">Community</h4> <h4 class="text-sm font-semibold text-foreground mb-4 uppercase tracking-wider">{{ 'components.footer.sections.community' | translate }}</h4>
<ul class="space-y-3"> <ul class="space-y-3">
<li> <li>
<a <a
@@ -74,7 +74,7 @@
height="16" height="16"
class="w-4 h-4 object-contain" class="w-4 h-4 object-contain"
/> />
Source Code {{ 'components.footer.links.sourceCode' | translate }}
</a> </a>
</li> </li>
<li> <li>
@@ -91,7 +91,7 @@
height="16" height="16"
class="w-4 h-4 object-contain" class="w-4 h-4 object-contain"
/> />
GitHub {{ 'components.footer.links.github' | translate }}
</a> </a>
</li> </li>
<li> <li>
@@ -108,7 +108,7 @@
height="16" height="16"
class="w-4 h-4 object-contain" class="w-4 h-4 object-contain"
/> />
Support Us {{ 'components.footer.links.supportUs' | translate }}
</a> </a>
</li> </li>
</ul> </ul>
@@ -116,30 +116,30 @@
<!-- Values --> <!-- Values -->
<div> <div>
<h4 class="text-sm font-semibold text-foreground mb-4 uppercase tracking-wider">Values</h4> <h4 class="text-sm font-semibold text-foreground mb-4 uppercase tracking-wider">{{ 'components.footer.sections.values' | translate }}</h4>
<ul class="space-y-3"> <ul class="space-y-3">
<li> <li>
<a <a
routerLink="/philosophy" routerLink="/philosophy"
class="text-sm text-muted-foreground hover:text-foreground transition-colors" class="text-sm text-muted-foreground hover:text-foreground transition-colors"
> >
Our Philosophy {{ 'components.footer.links.ourPhilosophy' | translate }}
</a> </a>
</li> </li>
<li><span class="text-sm text-muted-foreground">100% Free Forever</span></li> <li><span class="text-sm text-muted-foreground">{{ 'components.footer.values.freeForever' | translate }}</span></li>
<li><span class="text-sm text-muted-foreground">Open Source</span></li> <li><span class="text-sm text-muted-foreground">{{ 'components.footer.values.openSource' | translate }}</span></li>
</ul> </ul>
</div> </div>
</div> </div>
<div class="mt-12 pt-8 border-t border-border/30 flex flex-col md:flex-row justify-between items-center gap-4"> <div class="mt-12 pt-8 border-t border-border/30 flex flex-col md:flex-row justify-between items-center gap-4">
<p class="text-xs text-muted-foreground">&copy; {{ currentYear }} Myxelium. Toju is open-source software.</p> <p class="text-xs text-muted-foreground">{{ 'components.footer.copyright' | translate:{ year: currentYear } }}</p>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<a <a
href="https://git.azaaxin.com/myxelium/Toju" href="https://git.azaaxin.com/myxelium/Toju"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
aria-label="View source code on Gitea" [attr.aria-label]="'components.footer.viewSourceOnGitea' | translate"
class="text-muted-foreground hover:text-foreground transition-colors" class="text-muted-foreground hover:text-foreground transition-colors"
> >
<img <img
@@ -154,7 +154,7 @@
href="https://github.com/Myxelium" href="https://github.com/Myxelium"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
aria-label="View the project on GitHub" [attr.aria-label]="'components.footer.viewProjectOnGitHub' | translate"
class="text-muted-foreground hover:text-foreground transition-colors" class="text-muted-foreground hover:text-foreground transition-colors"
> >
<img <img

View File

@@ -1,10 +1,11 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
@Component({ @Component({
selector: 'app-footer', selector: 'app-footer',
standalone: true, standalone: true,
imports: [RouterLink], imports: [RouterLink, TranslateModule],
templateUrl: './footer.component.html' templateUrl: './footer.component.html'
}) })
export class FooterComponent { export class FooterComponent {

View File

@@ -6,21 +6,21 @@
<!-- Logo --> <!-- Logo -->
<a <a
routerLink="/" routerLink="/"
aria-label="Toju home" [attr.aria-label]="'components.header.homeAriaLabel' | translate"
class="flex items-center group" class="flex items-center group"
> >
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5">
<img <img
src="/images/toju-logo-transparent.png" src="/images/toju-logo-transparent.png"
alt="Toju" [attr.alt]="'common.brand' | translate"
class="h-9 w-auto object-contain drop-shadow-lg group-hover:opacity-90 transition-opacity" class="h-9 w-auto object-contain drop-shadow-lg group-hover:opacity-90 transition-opacity"
/> />
<span class="text-xl font-bold text-foreground">Toju</span> <span class="text-xl font-bold text-foreground">{{ 'common.brand' | translate }}</span>
</span> </span>
<span <span
class="ml-2 text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-purple-500/20 text-purple-400 border border-purple-500/30 uppercase tracking-wider" class="ml-2 text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-purple-500/20 text-purple-400 border border-purple-500/30 uppercase tracking-wider"
> >
Beta {{ 'components.header.beta' | translate }}
</span> </span>
</a> </a>
@@ -32,28 +32,28 @@
[routerLinkActiveOptions]="{ exact: true }" [routerLinkActiveOptions]="{ exact: true }"
class="text-sm text-muted-foreground hover:text-foreground transition-colors" class="text-sm text-muted-foreground hover:text-foreground transition-colors"
> >
Home {{ 'components.header.navigation.home' | translate }}
</a> </a>
<a <a
routerLink="/what-is-toju" routerLink="/what-is-toju"
routerLinkActive="text-primary" routerLinkActive="text-primary"
class="text-sm text-muted-foreground hover:text-foreground transition-colors" class="text-sm text-muted-foreground hover:text-foreground transition-colors"
> >
What is Toju? {{ 'components.header.navigation.whatIsToju' | translate }}
</a> </a>
<a <a
routerLink="/downloads" routerLink="/downloads"
routerLinkActive="text-primary" routerLinkActive="text-primary"
class="text-sm text-muted-foreground hover:text-foreground transition-colors" class="text-sm text-muted-foreground hover:text-foreground transition-colors"
> >
Downloads {{ 'components.header.navigation.downloads' | translate }}
</a> </a>
<a <a
routerLink="/philosophy" routerLink="/philosophy"
routerLinkActive="text-primary" routerLinkActive="text-primary"
class="text-sm text-muted-foreground hover:text-foreground transition-colors" class="text-sm text-muted-foreground hover:text-foreground transition-colors"
> >
Our Philosophy {{ 'components.header.navigation.philosophy' | translate }}
</a> </a>
</div> </div>
@@ -72,7 +72,7 @@
height="16" height="16"
class="w-4 h-4 object-contain" class="w-4 h-4 object-contain"
/> />
Support Us {{ 'components.header.supportUs' | translate }}
</a> </a>
<a <a
href="https://web.toju.app/" href="https://web.toju.app/"
@@ -80,7 +80,7 @@
rel="noopener" rel="noopener"
class="inline-flex items-center gap-2 px-5 py-2 rounded-lg bg-gradient-to-r from-purple-600 to-violet-600 text-white text-sm font-medium hover:from-purple-500 hover:to-violet-500 transition-all shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40" class="inline-flex items-center gap-2 px-5 py-2 rounded-lg bg-gradient-to-r from-purple-600 to-violet-600 text-white text-sm font-medium hover:from-purple-500 hover:to-violet-500 transition-all shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40"
> >
Use Web Version {{ 'components.header.useWebVersion' | translate }}
<svg <svg
class="w-4 h-4" class="w-4 h-4"
fill="none" fill="none"
@@ -102,7 +102,7 @@
type="button" type="button"
class="md:hidden text-foreground p-2" class="md:hidden text-foreground p-2"
(click)="mobileOpen.set(!mobileOpen())" (click)="mobileOpen.set(!mobileOpen())"
aria-label="Toggle menu" [attr.aria-label]="'components.header.toggleMenu' | translate"
> >
<svg <svg
class="w-6 h-6" class="w-6 h-6"
@@ -138,22 +138,22 @@
<a <a
routerLink="/" routerLink="/"
class="block text-sm text-muted-foreground hover:text-foreground transition-colors" class="block text-sm text-muted-foreground hover:text-foreground transition-colors"
>Home</a >{{ 'components.header.navigation.home' | translate }}</a
> >
<a <a
routerLink="/what-is-toju" routerLink="/what-is-toju"
class="block text-sm text-muted-foreground hover:text-foreground transition-colors" class="block text-sm text-muted-foreground hover:text-foreground transition-colors"
>What is Toju?</a >{{ 'components.header.navigation.whatIsToju' | translate }}</a
> >
<a <a
routerLink="/downloads" routerLink="/downloads"
class="block text-sm text-muted-foreground hover:text-foreground transition-colors" class="block text-sm text-muted-foreground hover:text-foreground transition-colors"
>Downloads</a >{{ 'components.header.navigation.downloads' | translate }}</a
> >
<a <a
routerLink="/philosophy" routerLink="/philosophy"
class="block text-sm text-muted-foreground hover:text-foreground transition-colors" class="block text-sm text-muted-foreground hover:text-foreground transition-colors"
>Our Philosophy</a >{{ 'components.header.navigation.philosophy' | translate }}</a
> >
<hr class="border-border/30" /> <hr class="border-border/30" />
<a <a
@@ -169,7 +169,7 @@
height="16" height="16"
class="w-4 h-4 object-contain" class="w-4 h-4 object-contain"
/> />
Support Us {{ 'components.header.supportUs' | translate }}
</a> </a>
<a <a
href="https://web.toju.app/" href="https://web.toju.app/"
@@ -177,7 +177,7 @@
rel="noopener" rel="noopener"
class="inline-flex items-center gap-2 px-5 py-2 rounded-lg bg-gradient-to-r from-purple-600 to-violet-600 text-white text-sm font-medium" class="inline-flex items-center gap-2 px-5 py-2 rounded-lg bg-gradient-to-r from-purple-600 to-violet-600 text-white text-sm font-medium"
> >
Use Web Version {{ 'components.header.useWebVersion' | translate }}
</a> </a>
</div> </div>
} }

View File

@@ -5,13 +5,18 @@ import {
HostListener, HostListener,
PLATFORM_ID PLATFORM_ID
} from '@angular/core'; } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { RouterLink, RouterLinkActive } from '@angular/router'; import { RouterLink, RouterLinkActive } from '@angular/router';
import { isPlatformBrowser } from '@angular/common'; import { isPlatformBrowser } from '@angular/common';
@Component({ @Component({
selector: 'app-header', selector: 'app-header',
standalone: true, standalone: true,
imports: [RouterLink, RouterLinkActive], imports: [
RouterLink,
RouterLinkActive,
TranslateModule
],
templateUrl: './header.component.html' templateUrl: './header.component.html'
}) })
export class HeaderComponent { export class HeaderComponent {

View File

@@ -1,9 +1,9 @@
<div class="min-h-screen pt-32 pb-20"> <div class="min-h-screen pt-32 pb-20">
<section class="container mx-auto px-6 mb-16"> <section class="container mx-auto px-6 mb-16">
<div class="max-w-3xl mx-auto text-center"> <div class="max-w-3xl mx-auto text-center">
<h1 class="text-4xl md:text-6xl font-extrabold text-foreground mb-6">Download <span class="gradient-text">Toju</span></h1> <h1 class="text-4xl md:text-6xl font-extrabold text-foreground mb-6">{{ 'pages.downloads.hero.titlePrefix' | translate }} <span class="gradient-text">{{ 'common.brand' | translate }}</span></h1>
<p class="text-lg text-muted-foreground leading-relaxed"> <p class="text-lg text-muted-foreground leading-relaxed">
Available for Windows, Linux, and in your browser. Always free, always the full experience. {{ 'pages.downloads.hero.description' | translate }}
</p> </p>
</div> </div>
</section> </section>
@@ -18,10 +18,10 @@
<div <div
class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 text-sm font-medium mb-4" class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 text-sm font-medium mb-4"
> >
Recommended for you {{ 'pages.downloads.recommended.badge' | translate }}
</div> </div>
<h2 class="text-2xl font-bold text-foreground mb-2">Toju for {{ detectedOS().name }}</h2> <h2 class="text-2xl font-bold text-foreground mb-2">{{ 'pages.downloads.recommended.title' | translate:{ os: getDetectedOsLabel() } }}</h2>
<p class="text-muted-foreground mb-6">Version {{ latestRelease()!.tag_name }}</p> <p class="text-muted-foreground mb-6">{{ 'pages.downloads.recommended.version' | translate:{ version: latestRelease()!.tag_name } }}</p>
@if (recommendedUrl()) { @if (recommendedUrl()) {
<a <a
@@ -41,21 +41,21 @@
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/> />
</svg> </svg>
Download for {{ detectedOS().name }} {{ 'common.actions.downloadFor' | translate:{ os: getDetectedOsLabel() } }}
</a> </a>
} }
<p class="text-xs text-muted-foreground/60 mt-4"> <p class="text-xs text-muted-foreground/60 mt-4">
Or {{ 'pages.downloads.recommended.webVersionPrefix' | translate }}
<a <a
href="https://web.toju.app/" href="https://web.toju.app/"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
class="underline hover:text-muted-foreground transition-colors" class="underline hover:text-muted-foreground transition-colors"
> >
use the web version {{ 'pages.downloads.recommended.webVersionLink' | translate }}
</a> </a>
- no download required. {{ 'pages.downloads.recommended.webVersionSuffix' | translate }}
</p> </p>
</div> </div>
</div> </div>
@@ -69,7 +69,7 @@
<section class="container mx-auto px-6 mb-20"> <section class="container mx-auto px-6 mb-20">
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<h2 class="text-2xl font-bold text-foreground mb-8 section-fade"> <h2 class="text-2xl font-bold text-foreground mb-8 section-fade">
All platforms <span class="text-muted-foreground font-normal text-lg">- {{ release.tag_name }}</span> {{ 'pages.downloads.allPlatforms.title' | translate }} <span class="text-muted-foreground font-normal text-lg">- {{ release.tag_name }}</span>
</h2> </h2>
<div class="grid gap-3 section-fade"> <div class="grid gap-3 section-fade">
@@ -84,7 +84,7 @@
@if (getOsIcon(asset.name)) { @if (getOsIcon(asset.name)) {
<img <img
[src]="getOsIcon(asset.name)" [src]="getOsIcon(asset.name)"
[alt]="releaseService.getAssetOS(asset.name) + ' icon'" [attr.alt]="'pages.downloads.allPlatforms.assetIconAlt' | translate:{ os: getAssetOSLabel(asset.name) }"
width="32" width="32"
height="32" height="32"
class="w-8 h-8 object-contain invert" class="w-8 h-8 object-contain invert"
@@ -94,7 +94,7 @@
<div> <div>
<p class="text-sm font-medium text-foreground group-hover:text-purple-400 transition-colors">{{ asset.name }}</p> <p class="text-sm font-medium text-foreground group-hover:text-purple-400 transition-colors">{{ asset.name }}</p>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
{{ releaseService.getAssetOS(asset.name) }} · {{ releaseService.formatBytes(asset.size) }} {{ getAssetOSLabel(asset.name) }} · {{ releaseService.formatBytes(asset.size) }}
</p> </p>
</div> </div>
</div> </div>
@@ -123,7 +123,7 @@
@if (releases().length > 1) { @if (releases().length > 1) {
<section class="container mx-auto px-6 mb-20"> <section class="container mx-auto px-6 mb-20">
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<h2 class="text-2xl font-bold text-foreground mb-8 section-fade">Previous Releases</h2> <h2 class="text-2xl font-bold text-foreground mb-8 section-fade">{{ 'pages.downloads.previousReleases.title' | translate }}</h2>
<div class="space-y-4 section-fade"> <div class="space-y-4 section-fade">
@for (release of releases().slice(1); track release.tag_name) { @for (release of releases().slice(1); track release.tag_name) {
@@ -147,7 +147,7 @@
</div> </div>
<div> <div>
<p class="text-sm font-medium text-foreground">{{ release.name || release.tag_name }}</p> <p class="text-sm font-medium text-foreground">{{ release.name || release.tag_name }}</p>
<p class="text-xs text-muted-foreground">{{ formatDate(release.published_at) }} · {{ release.assets.length }} files</p> <p class="text-xs text-muted-foreground">{{ formatDate(release.published_at) }} · {{ 'pages.downloads.previousReleases.fileCount' | translate:{ count: release.assets.length } }}</p>
</div> </div>
</div> </div>
<svg <svg
@@ -178,7 +178,7 @@
@if (getOsIcon(asset.name)) { @if (getOsIcon(asset.name)) {
<img <img
[src]="getOsIcon(asset.name)" [src]="getOsIcon(asset.name)"
[alt]="releaseService.getAssetOS(asset.name) + ' icon'" [attr.alt]="'pages.downloads.allPlatforms.assetIconAlt' | translate:{ os: getAssetOSLabel(asset.name) }"
width="16" width="16"
height="16" height="16"
class="w-4 h-4 object-contain mr-1 invert" class="w-4 h-4 object-contain mr-1 invert"
@@ -236,7 +236,7 @@
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path> ></path>
</svg> </svg>
Fetching releases... {{ 'pages.downloads.loading' | translate }}
</div> </div>
</div> </div>
} }
@@ -254,14 +254,14 @@
d="M6.18 15.64a2.18 2.18 0 012.18 2.18C8.36 19 7.38 20 6.18 20 5 20 4 19 4 17.82a2.18 2.18 0 012.18-2.18M4 4.44A15.56 15.56 0 0119.56 20h-2.83A12.73 12.73 0 004 7.27V4.44m0 5.66a9.9 9.9 0 019.9 9.9h-2.83A7.07 7.07 0 004 12.93V10.1z" d="M6.18 15.64a2.18 2.18 0 012.18 2.18C8.36 19 7.38 20 6.18 20 5 20 4 19 4 17.82a2.18 2.18 0 012.18-2.18M4 4.44A15.56 15.56 0 0119.56 20h-2.83A12.73 12.73 0 004 7.27V4.44m0 5.66a9.9 9.9 0 019.9 9.9h-2.83A7.07 7.07 0 004 12.93V10.1z"
/> />
</svg> </svg>
Stay updated with our {{ 'pages.downloads.rss.prefix' | translate }}
<a <a
href="https://git.azaaxin.com/myxelium/Toju/releases.rss" href="https://git.azaaxin.com/myxelium/Toju/releases.rss"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
class="underline hover:text-foreground transition-colors" class="underline hover:text-foreground transition-colors"
> >
RSS feed {{ 'pages.downloads.rss.link' | translate }}
</a> </a>
</p> </p>
</div> </div>

View File

@@ -8,6 +8,7 @@ import {
signal signal
} from '@angular/core'; } from '@angular/core';
import { isPlatformBrowser } from '@angular/common'; import { isPlatformBrowser } from '@angular/common';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { import {
ReleaseService, ReleaseService,
Release, Release,
@@ -21,7 +22,7 @@ import { getOsIconPath } from './os-icon.util';
@Component({ @Component({
selector: 'app-downloads', selector: 'app-downloads',
standalone: true, standalone: true,
imports: [AdSlotComponent], imports: [AdSlotComponent, TranslateModule],
templateUrl: './downloads.component.html' templateUrl: './downloads.component.html'
}) })
export class DownloadsComponent implements OnInit, AfterViewInit, OnDestroy { export class DownloadsComponent implements OnInit, AfterViewInit, OnDestroy {
@@ -29,7 +30,7 @@ export class DownloadsComponent implements OnInit, AfterViewInit, OnDestroy {
readonly releases = signal<Release[]>([]); readonly releases = signal<Release[]>([]);
readonly latestRelease = signal<Release | null>(null); readonly latestRelease = signal<Release | null>(null);
readonly detectedOS = signal<DetectedOS>({ readonly detectedOS = signal<DetectedOS>({
name: 'Linux', key: 'linux',
icon: '🐧', icon: '🐧',
filePattern: /\.AppImage$/i, filePattern: /\.AppImage$/i,
ymlFile: 'latest-linux.yml' ymlFile: 'latest-linux.yml'
@@ -40,11 +41,10 @@ export class DownloadsComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly seoService = inject(SeoService); private readonly seoService = inject(SeoService);
private readonly scrollAnimation = inject(ScrollAnimationService); private readonly scrollAnimation = inject(ScrollAnimationService);
private readonly platformId = inject(PLATFORM_ID); private readonly platformId = inject(PLATFORM_ID);
private readonly translate = inject(TranslateService);
ngOnInit(): void { ngOnInit(): void {
this.seoService.update({ this.seoService.updateFromTranslations('pages.downloads.seo', {
title: 'Download Toju',
description: 'Download Toju for Windows, Linux, or use the web version. Free peer-to-peer voice chat, screen sharing, and file transfers.',
url: 'https://toju.app/downloads' url: 'https://toju.app/downloads'
}); });
@@ -89,6 +89,14 @@ export class DownloadsComponent implements OnInit, AfterViewInit, OnDestroy {
return getOsIconPath(name, size); return getOsIconPath(name, size);
} }
getDetectedOsLabel(): string {
return this.translate.instant(`common.os.${this.detectedOS().key}`);
}
getAssetOSLabel(name: string): string {
return this.translate.instant(`common.os.${this.releaseService.getAssetOSKey(name)}`);
}
formatDate(dateStr: string): string { formatDate(dateStr: string): string {
try { try {
return new Date(dateStr).toLocaleDateString('en-US', { return new Date(dateStr).toLocaleDateString('en-US', {

View File

@@ -4,11 +4,11 @@
<div <div
class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 text-sm font-medium mb-6" class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 text-sm font-medium mb-6"
> >
Image Gallery {{ 'pages.gallery.hero.badge' | translate }}
</div> </div>
<h1 class="text-4xl md:text-6xl font-extrabold text-foreground mb-6">A closer look at <span class="gradient-text">Toju</span></h1> <h1 class="text-4xl md:text-6xl font-extrabold text-foreground mb-6">{{ 'pages.gallery.hero.titlePrefix' | translate }} <span class="gradient-text">{{ 'common.brand' | translate }}</span></h1>
<p class="text-lg text-muted-foreground leading-relaxed"> <p class="text-lg text-muted-foreground leading-relaxed">
Explore screenshots of the app experience, from voice chat and media sharing to servers, rooms, and full-screen collaboration. {{ 'pages.gallery.hero.description' | translate }}
</p> </p>
</div> </div>
</section> </section>
@@ -19,7 +19,7 @@
<div class="relative aspect-[16/9]"> <div class="relative aspect-[16/9]">
<img <img
ngSrc="/images/screenshots/screenshot_main.png" ngSrc="/images/screenshots/screenshot_main.png"
alt="Toju main application screenshot" [attr.alt]="'pages.gallery.featured.imageAlt' | translate"
fill fill
priority priority
sizes="(min-width: 1536px) 75vw, (min-width: 1280px) 90vw, 100vw" sizes="(min-width: 1536px) 75vw, (min-width: 1280px) 90vw, 100vw"
@@ -27,10 +27,10 @@
/> />
</div> </div>
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-background via-background/80 to-transparent p-6 md:p-8"> <div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-background via-background/80 to-transparent p-6 md:p-8">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-purple-400 mb-2">Featured</p> <p class="text-xs font-semibold uppercase tracking-[0.2em] text-purple-400 mb-2">{{ 'pages.gallery.featured.label' | translate }}</p>
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-2">The full Toju workspace</h2> <h2 class="text-2xl md:text-3xl font-bold text-foreground mb-2">{{ 'pages.gallery.featured.title' | translate }}</h2>
<p class="max-w-2xl text-sm md:text-base text-muted-foreground"> <p class="max-w-2xl text-sm md:text-base text-muted-foreground">
See the main interface where rooms, messages, presence, and media all come together in one focused layout. {{ 'pages.gallery.featured.description' | translate }}
</p> </p>
</div> </div>
</div> </div>
@@ -52,15 +52,15 @@
<div class="relative aspect-video overflow-hidden"> <div class="relative aspect-video overflow-hidden">
<img <img
[ngSrc]="item.src" [ngSrc]="item.src"
[alt]="item.title" [attr.alt]="item.titleKey | translate"
fill fill
sizes="(max-width: 768px) 100vw, (max-width: 1280px) 50vw, 33vw" sizes="(max-width: 768px) 100vw, (max-width: 1280px) 50vw, 33vw"
class="object-cover transition-transform duration-500 group-hover:scale-105" class="object-cover transition-transform duration-500 group-hover:scale-105"
/> />
</div> </div>
<div class="p-5"> <div class="p-5">
<h3 class="text-lg font-semibold text-foreground mb-2">{{ item.title }}</h3> <h3 class="text-lg font-semibold text-foreground mb-2">{{ item.titleKey | translate }}</h3>
<p class="text-sm text-muted-foreground leading-relaxed">{{ item.description }}</p> <p class="text-sm text-muted-foreground leading-relaxed">{{ item.descriptionKey | translate }}</p>
</div> </div>
</a> </a>
} }
@@ -72,14 +72,14 @@
<div <div
class="max-w-4xl mx-auto section-fade rounded-3xl border border-purple-500/20 bg-gradient-to-br from-purple-950/20 to-violet-950/20 p-8 md:p-10 text-center" class="max-w-4xl mx-auto section-fade rounded-3xl border border-purple-500/20 bg-gradient-to-br from-purple-950/20 to-violet-950/20 p-8 md:p-10 text-center"
> >
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-4">Want to see it in action?</h2> <h2 class="text-2xl md:text-3xl font-bold text-foreground mb-4">{{ 'pages.gallery.cta.title' | translate }}</h2>
<p class="text-muted-foreground leading-relaxed mb-6">Download Toju or jump into the browser experience and explore the interface yourself.</p> <p class="text-muted-foreground leading-relaxed mb-6">{{ 'pages.gallery.cta.description' | translate }}</p>
<div class="flex flex-col sm:flex-row items-center justify-center gap-4"> <div class="flex flex-col sm:flex-row items-center justify-center gap-4">
<a <a
routerLink="/downloads" routerLink="/downloads"
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold hover:from-purple-500 hover:to-violet-500 transition-all shadow-lg shadow-purple-500/25" class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold hover:from-purple-500 hover:to-violet-500 transition-all shadow-lg shadow-purple-500/25"
> >
Go to downloads {{ 'common.actions.goToDownloads' | translate }}
</a> </a>
<a <a
href="https://web.toju.app/" href="https://web.toju.app/"
@@ -87,7 +87,7 @@
rel="noopener" rel="noopener"
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium hover:border-purple-500/30 transition-all" class="inline-flex items-center gap-2 px-6 py-3 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium hover:border-purple-500/30 transition-all"
> >
Open web version {{ 'common.actions.openWebVersion' | translate }}
</a> </a>
</div> </div>
</div> </div>

View File

@@ -7,6 +7,7 @@ import {
inject inject
} from '@angular/core'; } from '@angular/core';
import { isPlatformBrowser, NgOptimizedImage } from '@angular/common'; import { isPlatformBrowser, NgOptimizedImage } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { AdSlotComponent } from '../../components/ad-slot/ad-slot.component'; import { AdSlotComponent } from '../../components/ad-slot/ad-slot.component';
import { ScrollAnimationService } from '../../services/scroll-animation.service'; import { ScrollAnimationService } from '../../services/scroll-animation.service';
@@ -14,8 +15,8 @@ import { SeoService } from '../../services/seo.service';
interface GalleryItem { interface GalleryItem {
src: string; src: string;
title: string; titleKey: string;
description: string; descriptionKey: string;
} }
@Component({ @Component({
@@ -23,6 +24,7 @@ interface GalleryItem {
standalone: true, standalone: true,
imports: [ imports: [
NgOptimizedImage, NgOptimizedImage,
TranslateModule,
RouterLink, RouterLink,
AdSlotComponent AdSlotComponent
], ],
@@ -32,38 +34,38 @@ export class GalleryComponent implements OnInit, AfterViewInit, OnDestroy {
readonly galleryItems: GalleryItem[] = [ readonly galleryItems: GalleryItem[] = [
{ {
src: '/images/screenshots/screenshot_main.png', src: '/images/screenshots/screenshot_main.png',
title: 'Main chat view', titleKey: 'pages.gallery.items.mainChatView.title',
description: 'The core Toju experience with channels, messages, and direct communication tools.' descriptionKey: 'pages.gallery.items.mainChatView.description'
}, },
{ {
src: '/images/screenshots/screenshare_gaming.png', src: '/images/screenshots/screenshare_gaming.png',
title: 'Gaming screen share', titleKey: 'pages.gallery.items.gamingScreenShare.title',
description: 'Share gameplay, guides, and live moments with smooth full-resolution screen sharing.' descriptionKey: 'pages.gallery.items.gamingScreenShare.description'
}, },
{ {
src: '/images/screenshots/serverViewScreen.png', src: '/images/screenshots/serverViewScreen.png',
title: 'Server overview', titleKey: 'pages.gallery.items.serverOverview.title',
description: 'Navigate servers and rooms with a layout designed for clarity and speed.' descriptionKey: 'pages.gallery.items.serverOverview.description'
}, },
{ {
src: '/images/screenshots/music.png', src: '/images/screenshots/music.png',
title: 'Music and voice', titleKey: 'pages.gallery.items.musicAndVoice.title',
description: 'Stay in sync with voice and media features in a focused, low-friction interface.' descriptionKey: 'pages.gallery.items.musicAndVoice.description'
}, },
{ {
src: '/images/screenshots/videos.png', src: '/images/screenshots/videos.png',
title: 'Video sharing', titleKey: 'pages.gallery.items.videoSharing.title',
description: 'Preview and share visual content directly with your friends and communities.' descriptionKey: 'pages.gallery.items.videoSharing.description'
}, },
{ {
src: '/images/screenshots/filedownload.png', src: '/images/screenshots/filedownload.png',
title: 'File transfers', titleKey: 'pages.gallery.items.fileTransfers.title',
description: 'Move files quickly without artificial size limits or unnecessary hoops.' descriptionKey: 'pages.gallery.items.fileTransfers.description'
}, },
{ {
src: '/images/screenshots/gif.png', src: '/images/screenshots/gif.png',
title: 'Rich media chat', titleKey: 'pages.gallery.items.richMediaChat.title',
description: 'Conversations stay lively with visual media support built right in.' descriptionKey: 'pages.gallery.items.richMediaChat.description'
} }
]; ];
@@ -72,9 +74,7 @@ export class GalleryComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly platformId = inject(PLATFORM_ID); private readonly platformId = inject(PLATFORM_ID);
ngOnInit(): void { ngOnInit(): void {
this.seoService.update({ this.seoService.updateFromTranslations('pages.gallery.seo', {
title: 'Toju Image Gallery',
description: 'Browse screenshots of Toju and explore the interface for chat, file sharing, voice, and screen sharing.',
url: 'https://toju.app/gallery' url: 'https://toju.app/gallery'
}); });
} }

View File

@@ -16,19 +16,19 @@
class="inline-flex items-center gap-2 px-4 py-1.5 rounded-full border border-purple-500/30 bg-purple-500/10 text-purple-400 text-sm font-medium mb-8 animate-fade-in" class="inline-flex items-center gap-2 px-4 py-1.5 rounded-full border border-purple-500/30 bg-purple-500/10 text-purple-400 text-sm font-medium mb-8 animate-fade-in"
> >
<span class="w-2 h-2 rounded-full bg-purple-400 animate-pulse"></span> <span class="w-2 h-2 rounded-full bg-purple-400 animate-pulse"></span>
Currently in Beta - Free &amp; Open Source {{ 'pages.home.hero.badge' | translate }}
</div> </div>
<h1 class="text-5xl md:text-7xl lg:text-8xl font-extrabold tracking-tight mb-6 animate-fade-in-up"> <h1 class="text-5xl md:text-7xl lg:text-8xl font-extrabold tracking-tight mb-6 animate-fade-in-up">
<span class="text-foreground">Talk freely.</span><br /> <span class="text-foreground">{{ 'pages.home.hero.titleLine1' | translate }}</span><br />
<span class="gradient-text">Own your voice.</span> <span class="gradient-text">{{ 'pages.home.hero.titleLine2' | translate }}</span>
</h1> </h1>
<p <p
class="text-lg md:text-xl text-muted-foreground max-w-2xl mx-auto mb-10 leading-relaxed animate-fade-in-up" class="text-lg md:text-xl text-muted-foreground max-w-2xl mx-auto mb-10 leading-relaxed animate-fade-in-up"
style="animation-delay: 0.2s" style="animation-delay: 0.2s"
> >
Crystal-clear voice calls, unlimited screen sharing, and file transfers with no size limits. Peer-to-peer. Private. Completely free. {{ 'pages.home.hero.description' | translate }}
</p> </p>
<!-- CTA Buttons --> <!-- CTA Buttons -->
@@ -54,7 +54,7 @@
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/> />
</svg> </svg>
Download for {{ detectedOS().name }} {{ 'common.actions.downloadFor' | translate:{ os: getDetectedOsLabel() } }}
<span class="text-sm opacity-75">{{ detectedOS().icon }}</span> <span class="text-sm opacity-75">{{ detectedOS().icon }}</span>
</a> </a>
} @else { } @else {
@@ -75,7 +75,7 @@
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/> />
</svg> </svg>
Download Toju {{ 'common.actions.downloadBrand' | translate }}
</a> </a>
} }
@@ -85,7 +85,7 @@
rel="noopener" rel="noopener"
class="inline-flex items-center gap-2 px-8 py-4 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium text-lg hover:bg-card hover:border-purple-500/30 transition-all backdrop-blur-sm" class="inline-flex items-center gap-2 px-8 py-4 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium text-lg hover:bg-card hover:border-purple-500/30 transition-all backdrop-blur-sm"
> >
Open in Browser {{ 'common.actions.openInBrowser' | translate }}
<svg <svg
class="w-5 h-5 opacity-60" class="w-5 h-5 opacity-60"
fill="none" fill="none"
@@ -107,11 +107,11 @@
class="text-xs text-muted-foreground/60 animate-fade-in" class="text-xs text-muted-foreground/60 animate-fade-in"
style="animation-delay: 0.6s" style="animation-delay: 0.6s"
> >
Version {{ latestVersion() }} · {{ 'pages.home.hero.version' | translate:{ version: latestVersion() } }} ·
<a <a
routerLink="/downloads" routerLink="/downloads"
class="underline hover:text-muted-foreground transition-colors" class="underline hover:text-muted-foreground transition-colors"
>All platforms</a >{{ 'pages.home.hero.allPlatforms' | translate }}</a
> >
</p> </p>
} }
@@ -142,10 +142,10 @@
<div class="container mx-auto px-6"> <div class="container mx-auto px-6">
<div class="text-center mb-20 section-fade"> <div class="text-center mb-20 section-fade">
<h2 class="text-3xl md:text-5xl font-bold text-foreground mb-4"> <h2 class="text-3xl md:text-5xl font-bold text-foreground mb-4">
Everything you need,<br /> {{ 'pages.home.features.titleLine1' | translate }}<br />
<span class="gradient-text">nothing you don't.</span> <span class="gradient-text">{{ 'pages.home.features.titleLine2' | translate }}</span>
</h2> </h2>
<p class="text-muted-foreground text-lg max-w-xl mx-auto">No bloat. No paywalls. Just the tools to connect with the people who matter.</p> <p class="text-muted-foreground text-lg max-w-xl mx-auto">{{ 'pages.home.features.description' | translate }}</p>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -168,9 +168,9 @@
/> />
</svg> </svg>
</div> </div>
<h3 class="text-xl font-semibold text-foreground mb-3">HD Voice Calls</h3> <h3 class="text-xl font-semibold text-foreground mb-3">{{ 'pages.home.features.items.voiceCalls.title' | translate }}</h3>
<p class="text-muted-foreground leading-relaxed"> <p class="text-muted-foreground leading-relaxed">
Crystal-clear audio with built-in noise reduction. Hear every word, not the background. No quality compromises, ever. {{ 'pages.home.features.items.voiceCalls.description' | translate }}
</p> </p>
</div> </div>
@@ -194,10 +194,9 @@
/> />
</svg> </svg>
</div> </div>
<h3 class="text-xl font-semibold text-foreground mb-3">Screen Sharing</h3> <h3 class="text-xl font-semibold text-foreground mb-3">{{ 'pages.home.features.items.screenSharing.title' | translate }}</h3>
<p class="text-muted-foreground leading-relaxed"> <p class="text-muted-foreground leading-relaxed">
Share your screen at full resolution. No time limits, no quality downgrades. Perfect for pair programming, presentations, or showing your {{ 'pages.home.features.items.screenSharing.description' | translate }}
epic gameplay.
</p> </p>
</div> </div>
@@ -221,9 +220,9 @@
/> />
</svg> </svg>
</div> </div>
<h3 class="text-xl font-semibold text-foreground mb-3">Unlimited File Sharing</h3> <h3 class="text-xl font-semibold text-foreground mb-3">{{ 'pages.home.features.items.fileSharing.title' | translate }}</h3>
<p class="text-muted-foreground leading-relaxed"> <p class="text-muted-foreground leading-relaxed">
Send files of any size directly to your friends. No upload limits, no compression. Your files go straight from you to them. {{ 'pages.home.features.items.fileSharing.description' | translate }}
</p> </p>
</div> </div>
@@ -246,10 +245,9 @@
/> />
</svg> </svg>
</div> </div>
<h3 class="text-xl font-semibold text-foreground mb-3">True Privacy</h3> <h3 class="text-xl font-semibold text-foreground mb-3">{{ 'pages.home.features.items.privacy.title' | translate }}</h3>
<p class="text-muted-foreground leading-relaxed"> <p class="text-muted-foreground leading-relaxed">
Peer-to-peer means your data goes directly between you and your friends. No servers storing your conversations. Your business is your {{ 'pages.home.features.items.privacy.description' | translate }}
business.
</p> </p>
</div> </div>
@@ -273,9 +271,9 @@
/> />
</svg> </svg>
</div> </div>
<h3 class="text-xl font-semibold text-foreground mb-3">Open Source</h3> <h3 class="text-xl font-semibold text-foreground mb-3">{{ 'pages.home.features.items.openSource.title' | translate }}</h3>
<p class="text-muted-foreground leading-relaxed"> <p class="text-muted-foreground leading-relaxed">
Every line of code is public. Audit it, modify it, host your own signal server. Full transparency - nothing is hidden. {{ 'pages.home.features.items.openSource.description' | translate }}
</p> </p>
</div> </div>
@@ -299,9 +297,9 @@
/> />
</svg> </svg>
</div> </div>
<h3 class="text-xl font-semibold text-foreground mb-3">Completely Free</h3> <h3 class="text-xl font-semibold text-foreground mb-3">{{ 'pages.home.features.items.free.title' | translate }}</h3>
<p class="text-muted-foreground leading-relaxed"> <p class="text-muted-foreground leading-relaxed">
No premium tiers. No paywalls. No "starter plans". Every feature is available to everyone, always. Made with love, not profit margins. {{ 'pages.home.features.items.free.description' | translate }}
</p> </p>
</div> </div>
</div> </div>
@@ -338,15 +336,14 @@
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/> />
</svg> </svg>
Built for Gamers {{ 'pages.home.gaming.badge' | translate }}
</div> </div>
<h2 class="text-3xl md:text-5xl font-bold text-foreground mb-6"> <h2 class="text-3xl md:text-5xl font-bold text-foreground mb-6">
Your perfect<br /> {{ 'pages.home.gaming.titleLine1' | translate }}<br />
<span class="gradient-text">gaming companion.</span> <span class="gradient-text">{{ 'pages.home.gaming.titleLine2' | translate }}</span>
</h2> </h2>
<p class="text-lg text-muted-foreground leading-relaxed mb-8"> <p class="text-lg text-muted-foreground leading-relaxed mb-8">
Ultra-low latency voice chat that doesn't eat your bandwidth. Share your screen without frame drops. Send clips and files instantly. All {{ 'pages.home.gaming.description' | translate }}
while keeping your CPU free for what matters - winning.
</p> </p>
<ul class="space-y-4"> <ul class="space-y-4">
<li class="flex items-center gap-3 text-muted-foreground"> <li class="flex items-center gap-3 text-muted-foreground">
@@ -363,7 +360,7 @@
d="M5 13l4 4L19 7" d="M5 13l4 4L19 7"
/> />
</svg> </svg>
<span>Low-latency peer-to-peer voice - no relay servers in the way</span> <span>{{ 'pages.home.gaming.bullets.lowLatency' | translate }}</span>
</li> </li>
<li class="flex items-center gap-3 text-muted-foreground"> <li class="flex items-center gap-3 text-muted-foreground">
<svg <svg
@@ -379,7 +376,7 @@
d="M5 13l4 4L19 7" d="M5 13l4 4L19 7"
/> />
</svg> </svg>
<span>AI-powered noise suppression - keyboard clatter stays out</span> <span>{{ 'pages.home.gaming.bullets.noiseSuppression' | translate }}</span>
</li> </li>
<li class="flex items-center gap-3 text-muted-foreground"> <li class="flex items-center gap-3 text-muted-foreground">
<svg <svg
@@ -395,7 +392,7 @@
d="M5 13l4 4L19 7" d="M5 13l4 4L19 7"
/> />
</svg> </svg>
<span>Full-resolution screen sharing at high FPS</span> <span>{{ 'pages.home.gaming.bullets.screenShare' | translate }}</span>
</li> </li>
<li class="flex items-center gap-3 text-muted-foreground"> <li class="flex items-center gap-3 text-muted-foreground">
<svg <svg
@@ -411,7 +408,7 @@
d="M5 13l4 4L19 7" d="M5 13l4 4L19 7"
/> />
</svg> </svg>
<span>Send replays and screenshots with no file size limit</span> <span>{{ 'pages.home.gaming.bullets.fileTransfers' | translate }}</span>
</li> </li>
</ul> </ul>
</div> </div>
@@ -422,11 +419,11 @@
ngSrc="/images/screenshots/screenshare_gaming.png" ngSrc="/images/screenshots/screenshare_gaming.png"
fill fill
priority priority
alt="Toju gaming screen sharing preview" [attr.alt]="'pages.home.gaming.imageAlt' | translate"
class="object-cover" class="object-cover"
/> />
<div class="absolute inset-0 bg-gradient-to-t from-background/80 via-transparent to-transparent"></div> <div class="absolute inset-0 bg-gradient-to-t from-background/80 via-transparent to-transparent"></div>
<div class="absolute bottom-4 left-4 text-sm text-muted-foreground/60">Game on. No limits.</div> <div class="absolute bottom-4 left-4 text-sm text-muted-foreground/60">{{ 'pages.home.gaming.caption' | translate }}</div>
</div> </div>
<!-- Glow effect --> <!-- Glow effect -->
<div class="absolute -inset-4 bg-purple-600/5 rounded-3xl blur-2xl -z-10"></div> <div class="absolute -inset-4 bg-purple-600/5 rounded-3xl blur-2xl -z-10"></div>
@@ -457,22 +454,21 @@
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"
/> />
</svg> </svg>
Self-Hostable {{ 'pages.home.selfHostable.badge' | translate }}
</div> </div>
<h2 class="text-3xl md:text-5xl font-bold text-foreground mb-6"> <h2 class="text-3xl md:text-5xl font-bold text-foreground mb-6">
Your infrastructure,<br /> {{ 'pages.home.selfHostable.titleLine1' | translate }}<br />
<span class="gradient-text">your rules.</span> <span class="gradient-text">{{ 'pages.home.selfHostable.titleLine2' | translate }}</span>
</h2> </h2>
<p class="text-lg text-muted-foreground leading-relaxed mb-8"> <p class="text-lg text-muted-foreground leading-relaxed mb-8">
Toju uses a lightweight coordination server to help peers find each other - that's it. Your actual conversations never touch a server. Want {{ 'pages.home.selfHostable.description' | translate }}
even more control? Run your own coordination server in minutes. Full independence, zero compromises.
</p> </p>
<div class="flex flex-col sm:flex-row items-center justify-center gap-4"> <div class="flex flex-col sm:flex-row items-center justify-center gap-4">
<a <a
routerLink="/what-is-toju" routerLink="/what-is-toju"
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium hover:border-purple-500/30 transition-all" class="inline-flex items-center gap-2 px-6 py-3 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium hover:border-purple-500/30 transition-all"
> >
Learn how it works {{ 'common.actions.learnHowItWorks' | translate }}
<svg <svg
class="w-4 h-4" class="w-4 h-4"
fill="none" fill="none"
@@ -500,7 +496,7 @@
height="16" height="16"
class="w-4 h-4 object-contain" class="w-4 h-4 object-contain"
/> />
View source code {{ 'common.actions.viewSourceCode' | translate }}
</a> </a>
</div> </div>
</div> </div>
@@ -511,22 +507,22 @@
<section class="relative py-24"> <section class="relative py-24">
<div class="absolute inset-0 bg-gradient-to-r from-purple-950/20 via-violet-950/30 to-purple-950/20"></div> <div class="absolute inset-0 bg-gradient-to-r from-purple-950/20 via-violet-950/30 to-purple-950/20"></div>
<div class="relative container mx-auto px-6 text-center section-fade"> <div class="relative container mx-auto px-6 text-center section-fade">
<h2 class="text-3xl md:text-4xl font-bold text-foreground mb-4">Ready to take back your conversations?</h2> <h2 class="text-3xl md:text-4xl font-bold text-foreground mb-4">{{ 'pages.home.cta.title' | translate }}</h2>
<p class="text-muted-foreground text-lg mb-8 max-w-lg mx-auto">Join thousands choosing privacy, freedom, and real connection.</p> <p class="text-muted-foreground text-lg mb-8 max-w-lg mx-auto">{{ 'pages.home.cta.description' | translate }}</p>
<div class="flex flex-col sm:flex-row items-center justify-center gap-4"> <div class="flex flex-col sm:flex-row items-center justify-center gap-4">
@if (downloadUrl()) { @if (downloadUrl()) {
<a <a
[href]="downloadUrl()" [href]="downloadUrl()"
class="inline-flex items-center gap-2 px-8 py-4 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold hover:from-purple-500 hover:to-violet-500 transition-all shadow-2xl shadow-purple-500/25" class="inline-flex items-center gap-2 px-8 py-4 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold hover:from-purple-500 hover:to-violet-500 transition-all shadow-2xl shadow-purple-500/25"
> >
Download for {{ detectedOS().name }} {{ 'common.actions.downloadFor' | translate:{ os: getDetectedOsLabel() } }}
</a> </a>
} @else { } @else {
<a <a
routerLink="/downloads" routerLink="/downloads"
class="inline-flex items-center gap-2 px-8 py-4 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold hover:from-purple-500 hover:to-violet-500 transition-all shadow-2xl shadow-purple-500/25" class="inline-flex items-center gap-2 px-8 py-4 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold hover:from-purple-500 hover:to-violet-500 transition-all shadow-2xl shadow-purple-500/25"
> >
Download Toju {{ 'common.actions.downloadBrand' | translate }}
</a> </a>
} }
<a <a
@@ -535,7 +531,7 @@
rel="noopener" rel="noopener"
class="inline-flex items-center gap-2 px-8 py-4 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium hover:border-purple-500/30 transition-all" class="inline-flex items-center gap-2 px-8 py-4 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium hover:border-purple-500/30 transition-all"
> >
Try in Browser {{ 'common.actions.tryInBrowser' | translate }}
</a> </a>
</div> </div>
</div> </div>

View File

@@ -8,6 +8,7 @@ import {
signal signal
} from '@angular/core'; } from '@angular/core';
import { isPlatformBrowser, NgOptimizedImage } from '@angular/common'; import { isPlatformBrowser, NgOptimizedImage } from '@angular/common';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { ReleaseService, DetectedOS } from '../../services/release.service'; import { ReleaseService, DetectedOS } from '../../services/release.service';
import { SeoService } from '../../services/seo.service'; import { SeoService } from '../../services/seo.service';
@@ -20,6 +21,7 @@ import { ParallaxDirective } from '../../directives/parallax.directive';
standalone: true, standalone: true,
imports: [ imports: [
NgOptimizedImage, NgOptimizedImage,
TranslateModule,
RouterLink, RouterLink,
AdSlotComponent, AdSlotComponent,
ParallaxDirective ParallaxDirective
@@ -28,7 +30,7 @@ import { ParallaxDirective } from '../../directives/parallax.directive';
}) })
export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
readonly detectedOS = signal<DetectedOS>({ readonly detectedOS = signal<DetectedOS>({
name: 'Linux', key: 'linux',
icon: '🐧', icon: '🐧',
filePattern: /\.AppImage$/i, filePattern: /\.AppImage$/i,
ymlFile: 'latest-linux.yml' ymlFile: 'latest-linux.yml'
@@ -40,13 +42,10 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly seoService = inject(SeoService); private readonly seoService = inject(SeoService);
private readonly scrollAnimation = inject(ScrollAnimationService); private readonly scrollAnimation = inject(ScrollAnimationService);
private readonly platformId = inject(PLATFORM_ID); private readonly platformId = inject(PLATFORM_ID);
private readonly translate = inject(TranslateService);
ngOnInit(): void { ngOnInit(): void {
this.seoService.update({ this.seoService.updateFromTranslations('pages.home.seo', {
title: 'Free Peer-to-Peer Voice, Video & Chat',
description:
'Toju is a free, open-source, peer-to-peer communication app. Crystal-clear voice calls, unlimited screen sharing, '
+ 'no file size limits, complete privacy.',
url: 'https://toju.app/' url: 'https://toju.app/'
}); });
@@ -74,4 +73,8 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
ngOnDestroy(): void { ngOnDestroy(): void {
this.scrollAnimation.destroy(); this.scrollAnimation.destroy();
} }
getDetectedOsLabel(): string {
return this.translate.instant(`common.os.${this.detectedOS().key}`);
}
} }

View File

@@ -5,10 +5,10 @@
<div <div
class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 text-sm font-medium mb-6" class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 text-sm font-medium mb-6"
> >
Our Manifesto {{ 'pages.philosophy.hero.badge' | translate }}
</div> </div>
<h1 class="text-4xl md:text-6xl font-extrabold text-foreground mb-6">Why we <span class="gradient-text">build</span> Toju</h1> <h1 class="text-4xl md:text-6xl font-extrabold text-foreground mb-6">{{ 'pages.philosophy.hero.titlePrefix' | translate }} <span class="gradient-text">{{ 'pages.philosophy.hero.titleHighlight' | translate }}</span> {{ 'common.brand' | translate }}</h1>
<p class="text-lg text-muted-foreground leading-relaxed">A letter from the people behind the project.</p> <p class="text-lg text-muted-foreground leading-relaxed">{{ 'pages.philosophy.hero.description' | translate }}</p>
</div> </div>
</section> </section>
@@ -19,81 +19,58 @@
<article class="max-w-3xl mx-auto prose prose-invert prose-lg"> <article class="max-w-3xl mx-auto prose prose-invert prose-lg">
<!-- Ownership --> <!-- Ownership -->
<div class="section-fade mb-16"> <div class="section-fade mb-16">
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6 !mt-0">We Lost Something Important</h2> <h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6 !mt-0">{{ 'pages.philosophy.sections.ownership.title' | translate }}</h2>
<p class="text-muted-foreground leading-relaxed"> <p class="text-muted-foreground leading-relaxed">
Over the past two decades, something fundamental shifted. Our conversations, our memories, our connections - they stopped belonging to us. {{ 'pages.philosophy.sections.ownership.paragraph1' | translate }}
They live on servers we don't control, inside apps that treat our personal lives as data to be harvested, analyzed, and sold to the highest
bidder.
</p> </p>
<p class="text-muted-foreground leading-relaxed"> <p class="text-muted-foreground leading-relaxed">
We gave up ownership of our digital lives so gradually that most of us didn't even notice. A "free" app here, a convenient service there - {{ 'pages.philosophy.sections.ownership.paragraph2' | translate }}
each one taking a little more of our privacy in exchange for convenience. Toju exists because we believe it's time to take it back.
</p> </p>
</div> </div>
<!-- No predatory pricing --> <!-- No predatory pricing -->
<div class="section-fade mb-16"> <div class="section-fade mb-16">
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">No Paywalls. No Premium Tiers. Ever.</h2> <h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">{{ 'pages.philosophy.sections.paywalls.title' | translate }}</h2>
<p class="text-muted-foreground leading-relaxed"> <p class="text-muted-foreground leading-relaxed">
You know the playbook: launch a free product, build a user base, then start locking features behind subscription tiers. Can't share your {{ 'pages.philosophy.sections.paywalls.paragraph1' | translate }}
screen at more than 720p unless you upgrade. File size limited to 8 MB. Want noise suppression? That's a premium feature now.
</p>
<p class="text-muted-foreground leading-relaxed">
We refuse to play that game. <strong class="text-foreground">Every feature in Toju is available to every user, always.</strong>
There is no "Toju Nitro," no "Pro plan," no artificial limitations designed to push you toward your wallet. Communication is a human need,
not a luxury - and the tools for it should reflect that.
</p> </p>
<p class="text-muted-foreground leading-relaxed" [innerHTML]="'pages.philosophy.sections.paywalls.paragraph2' | translate"></p>
</div> </div>
<app-ad-slot /> <app-ad-slot />
<!-- Privacy as a right --> <!-- Privacy as a right -->
<div class="section-fade mb-16"> <div class="section-fade mb-16">
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">Privacy Is a Right, Not a Feature</h2> <h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">{{ 'pages.philosophy.sections.privacy.title' | translate }}</h2>
<p class="text-muted-foreground leading-relaxed"> <p class="text-muted-foreground leading-relaxed">
Most communication platforms collect everything: who you talk to, when, for how long, what you share. They build profiles of your social {{ 'pages.philosophy.sections.privacy.paragraph1' | translate }}
graph, your habits, your interests. Even services that claim to care about privacy still store metadata on their servers.
</p>
<p class="text-muted-foreground leading-relaxed">
Toju is architecturally different. Your data goes directly from your device to your friend's device. We don't have your messages. We don't
have your files. We don't have your call history. Not because we promised not to look - but because the data never touches our
infrastructure. We built the technology so that
<strong class="text-foreground">privacy isn't something we offer; it's something we literally cannot violate.</strong>
</p> </p>
<p class="text-muted-foreground leading-relaxed" [innerHTML]="'pages.philosophy.sections.privacy.paragraph2' | translate"></p>
</div> </div>
<!-- Better world --> <!-- Better world -->
<div class="section-fade mb-16"> <div class="section-fade mb-16">
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">Built from the Heart</h2> <h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">{{ 'pages.philosophy.sections.heart.title' | translate }}</h2>
<p class="text-muted-foreground leading-relaxed"> <p class="text-muted-foreground leading-relaxed">
Toju wasn't born in a boardroom with revenue projections. It was born from frustration - frustration with being the product, with watching {{ 'pages.philosophy.sections.heart.paragraph1' | translate }}
friends get locked out of features they used to have for free, with the growing feeling that the tools we depend on daily don't actually
serve our interests.
</p>
<p class="text-muted-foreground leading-relaxed">
We build Toju because we genuinely want to make the world a little better. The internet was supposed to connect people freely, and somewhere
along the way, that mission got hijacked by business models that exploit the very connections they facilitate.
<strong class="text-foreground">Toju is our small act of reclaiming that original promise.</strong>
</p> </p>
<p class="text-muted-foreground leading-relaxed" [innerHTML]="'pages.philosophy.sections.heart.paragraph2' | translate"></p>
</div> </div>
<!-- Open source --> <!-- Open source -->
<div class="section-fade mb-16"> <div class="section-fade mb-16">
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">Transparent by Default</h2> <h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">{{ 'pages.philosophy.sections.openSource.title' | translate }}</h2>
<p class="text-muted-foreground leading-relaxed"> <p class="text-muted-foreground leading-relaxed">
Every line of Toju's code is publicly available. You can read it, audit it, contribute to it, or fork it and build your own version. This {{ 'pages.philosophy.sections.openSource.paragraph1' | translate }}
isn't a marketing decision - it's an accountability decision. When you can see exactly how the software works, you never have to take our
word for anything.
</p> </p>
<p class="text-muted-foreground leading-relaxed"> <p class="text-muted-foreground leading-relaxed">
Open source also means Toju belongs to its community, not to a company. Even if we stopped development tomorrow, the project lives on. Your {{ 'pages.philosophy.sections.openSource.paragraph2' | translate }}
communication infrastructure shouldn't depend on a single organization's survival.
</p> </p>
</div> </div>
<!-- Commitment --> <!-- Commitment -->
<div class="section-fade rounded-2xl border border-purple-500/20 bg-gradient-to-br from-purple-950/20 to-violet-950/20 p-8 md:p-10"> <div class="section-fade rounded-2xl border border-purple-500/20 bg-gradient-to-br from-purple-950/20 to-violet-950/20 p-8 md:p-10">
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6 !mt-0">Our Promise</h2> <h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6 !mt-0">{{ 'pages.philosophy.promise.title' | translate }}</h2>
<ul class="space-y-4 text-muted-foreground !list-none !pl-0"> <ul class="space-y-4 text-muted-foreground !list-none !pl-0">
<li class="flex items-start gap-3"> <li class="flex items-start gap-3">
<svg <svg
@@ -109,7 +86,7 @@
d="M5 13l4 4L19 7" d="M5 13l4 4L19 7"
/> />
</svg> </svg>
<span>We will <strong class="text-foreground">never</strong> lock features behind a paywall.</span> <span [innerHTML]="'pages.philosophy.promise.items.noPaywalls' | translate"></span>
</li> </li>
<li class="flex items-start gap-3"> <li class="flex items-start gap-3">
<svg <svg
@@ -125,7 +102,7 @@
d="M5 13l4 4L19 7" d="M5 13l4 4L19 7"
/> />
</svg> </svg>
<span>We will <strong class="text-foreground">never</strong> sell, monetize, or harvest your data.</span> <span [innerHTML]="'pages.philosophy.promise.items.noDataSales' | translate"></span>
</li> </li>
<li class="flex items-start gap-3"> <li class="flex items-start gap-3">
<svg <svg
@@ -141,7 +118,7 @@
d="M5 13l4 4L19 7" d="M5 13l4 4L19 7"
/> />
</svg> </svg>
<span>We will <strong class="text-foreground">always</strong> keep the source code open and auditable.</span> <span [innerHTML]="'pages.philosophy.promise.items.openSource' | translate"></span>
</li> </li>
<li class="flex items-start gap-3"> <li class="flex items-start gap-3">
<svg <svg
@@ -157,10 +134,10 @@
d="M5 13l4 4L19 7" d="M5 13l4 4L19 7"
/> />
</svg> </svg>
<span>We will <strong class="text-foreground">always</strong> put users before profit.</span> <span [innerHTML]="'pages.philosophy.promise.items.usersBeforeProfit' | translate"></span>
</li> </li>
</ul> </ul>
<p class="text-muted-foreground mt-6 text-sm">- The Myxelium team</p> <p class="text-muted-foreground mt-6 text-sm">{{ 'pages.philosophy.promise.signature' | translate }}</p>
</div> </div>
</article> </article>
</section> </section>
@@ -168,10 +145,9 @@
<!-- Support CTA --> <!-- Support CTA -->
<section class="container mx-auto px-6"> <section class="container mx-auto px-6">
<div class="section-fade max-w-2xl mx-auto text-center"> <div class="section-fade max-w-2xl mx-auto text-center">
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-4">Help us keep going</h2> <h2 class="text-2xl md:text-3xl font-bold text-foreground mb-4">{{ 'pages.philosophy.support.title' | translate }}</h2>
<p class="text-muted-foreground mb-8 leading-relaxed"> <p class="text-muted-foreground mb-8 leading-relaxed">
If Toju's mission resonates with you, consider supporting the project. Every contribution helps us keep the lights on and development moving {{ 'pages.philosophy.support.description' | translate }}
forward - without ever compromising our values.
</p> </p>
<div class="flex flex-col sm:flex-row items-center justify-center gap-4"> <div class="flex flex-col sm:flex-row items-center justify-center gap-4">
<a <a
@@ -187,13 +163,13 @@
height="20" height="20"
class="w-5 h-5 object-contain" class="w-5 h-5 object-contain"
/> />
Buy us a coffee {{ 'common.actions.buyUsCoffee' | translate }}
</a> </a>
<a <a
routerLink="/downloads" routerLink="/downloads"
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium hover:border-purple-500/30 transition-all" class="inline-flex items-center gap-2 px-6 py-3 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium hover:border-purple-500/30 transition-all"
> >
Download Toju {{ 'common.actions.downloadBrand' | translate }}
<svg <svg
class="w-4 h-4" class="w-4 h-4"
fill="none" fill="none"

View File

@@ -7,6 +7,7 @@ import {
PLATFORM_ID PLATFORM_ID
} from '@angular/core'; } from '@angular/core';
import { isPlatformBrowser } from '@angular/common'; import { isPlatformBrowser } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { SeoService } from '../../services/seo.service'; import { SeoService } from '../../services/seo.service';
import { ScrollAnimationService } from '../../services/scroll-animation.service'; import { ScrollAnimationService } from '../../services/scroll-animation.service';
@@ -15,7 +16,11 @@ import { AdSlotComponent } from '../../components/ad-slot/ad-slot.component';
@Component({ @Component({
selector: 'app-philosophy', selector: 'app-philosophy',
standalone: true, standalone: true,
imports: [RouterLink, AdSlotComponent], imports: [
RouterLink,
AdSlotComponent,
TranslateModule
],
templateUrl: './philosophy.component.html' templateUrl: './philosophy.component.html'
}) })
export class PhilosophyComponent implements OnInit, AfterViewInit, OnDestroy { export class PhilosophyComponent implements OnInit, AfterViewInit, OnDestroy {
@@ -24,11 +29,7 @@ export class PhilosophyComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly platformId = inject(PLATFORM_ID); private readonly platformId = inject(PLATFORM_ID);
ngOnInit(): void { ngOnInit(): void {
this.seoService.update({ this.seoService.updateFromTranslations('pages.philosophy.seo', {
title: 'Our Philosophy - Why We Build Toju',
description:
'Toju exists because privacy is a right, not a premium feature. No paywalls, no data harvesting, no predatory '
+ 'pricing. Learn why we build free, open-source communication tools.',
url: 'https://toju.app/philosophy' url: 'https://toju.app/philosophy'
}); });
} }

View File

@@ -5,12 +5,11 @@
<div <div
class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 text-sm font-medium mb-6" class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 text-sm font-medium mb-6"
> >
The Big Picture {{ 'pages.whatIsToju.hero.badge' | translate }}
</div> </div>
<h1 class="text-4xl md:text-6xl font-extrabold text-foreground mb-6">What is <span class="gradient-text">Toju</span>?</h1> <h1 class="text-4xl md:text-6xl font-extrabold text-foreground mb-6">{{ 'pages.whatIsToju.hero.titlePrefix' | translate }} <span class="gradient-text">{{ 'common.brand' | translate }}</span>?</h1>
<p class="text-lg text-muted-foreground leading-relaxed"> <p class="text-lg text-muted-foreground leading-relaxed">
Toju is a communication app that lets you voice chat, share your screen, send files, and message your friends - all without your data passing {{ 'pages.whatIsToju.hero.description' | translate }}
through someone else's servers. Think of it as your own private phone line that nobody can tap into.
</p> </p>
</div> </div>
</section> </section>
@@ -21,7 +20,7 @@
<section class="container mx-auto px-6 mb-24"> <section class="container mx-auto px-6 mb-24">
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<h2 class="text-3xl md:text-4xl font-bold text-foreground mb-12 text-center section-fade"> <h2 class="text-3xl md:text-4xl font-bold text-foreground mb-12 text-center section-fade">
How does it <span class="gradient-text">work</span>? {{ 'pages.whatIsToju.howItWorks.titlePrefix' | translate }} <span class="gradient-text">{{ 'pages.whatIsToju.howItWorks.titleHighlight' | translate }}</span>?
</h2> </h2>
<div class="grid gap-8"> <div class="grid gap-8">
@@ -34,13 +33,8 @@
1 1
</div> </div>
<div> <div>
<h3 class="text-xl font-semibold text-foreground mb-3">You connect directly to your friends</h3> <h3 class="text-xl font-semibold text-foreground mb-3">{{ 'pages.whatIsToju.steps.one.title' | translate }}</h3>
<p class="text-muted-foreground leading-relaxed"> <p class="text-muted-foreground leading-relaxed" [innerHTML]="'pages.whatIsToju.steps.one.description' | translate"></p>
When you start a call or send a file on Toju, your data travels directly from your device to your friend's device. There's no company
server in the middle storing your conversations, listening to your calls, or scanning your files. This is called
<strong class="text-foreground">peer-to-peer</strong> - it's like having a direct road between your houses instead of going through a
toll booth.
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -54,12 +48,8 @@
2 2
</div> </div>
<div> <div>
<h3 class="text-xl font-semibold text-foreground mb-3">A tiny helper gets you connected</h3> <h3 class="text-xl font-semibold text-foreground mb-3">{{ 'pages.whatIsToju.steps.two.title' | translate }}</h3>
<p class="text-muted-foreground leading-relaxed"> <p class="text-muted-foreground leading-relaxed" [innerHTML]="'pages.whatIsToju.steps.two.description' | translate"></p>
The only thing a server does is help your device find your friend's device - like a mutual friend introducing you at a party. Once
you're connected, the server steps out of the picture entirely. It never sees what you say, share, or send. This helper is called a
<strong class="text-foreground">signal server</strong>, and you can even run your own if you'd like.
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -73,12 +63,8 @@
3 3
</div> </div>
<div> <div>
<h3 class="text-xl font-semibold text-foreground mb-3">No limits because there are no middlemen</h3> <h3 class="text-xl font-semibold text-foreground mb-3">{{ 'pages.whatIsToju.steps.three.title' | translate }}</h3>
<p class="text-muted-foreground leading-relaxed"> <p class="text-muted-foreground leading-relaxed" [innerHTML]="'pages.whatIsToju.steps.three.description' | translate"></p>
Since your data doesn't pass through our servers, we don't need to pay for massive infrastructure. That's why Toju can offer
<strong class="text-foreground">unlimited screen sharing, file transfers of any size, and high-quality voice</strong> - all completely
free. There's no business reason to limit what you can do, and we never will.
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -92,7 +78,7 @@
<section class="container mx-auto px-6 mb-24"> <section class="container mx-auto px-6 mb-24">
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<h2 class="text-3xl md:text-4xl font-bold text-foreground mb-12 text-center section-fade"> <h2 class="text-3xl md:text-4xl font-bold text-foreground mb-12 text-center section-fade">
Why is it <span class="gradient-text">designed</span> this way? {{ 'pages.whatIsToju.whyDesigned.titlePrefix' | translate }} <span class="gradient-text">{{ 'pages.whatIsToju.whyDesigned.titleHighlight' | translate }}</span> {{ 'pages.whatIsToju.whyDesigned.titleSuffix' | translate }}
</h2> </h2>
<div class="grid md:grid-cols-2 gap-8"> <div class="grid md:grid-cols-2 gap-8">
@@ -112,10 +98,9 @@
/> />
</svg> </svg>
</div> </div>
<h3 class="text-lg font-semibold text-foreground mb-3">Privacy by Architecture</h3> <h3 class="text-lg font-semibold text-foreground mb-3">{{ 'pages.whatIsToju.benefits.privacyArchitecture.title' | translate }}</h3>
<p class="text-muted-foreground leading-relaxed text-sm"> <p class="text-muted-foreground leading-relaxed text-sm">
We didn't just add privacy as a feature - we built the entire app around it. When there's no central server handling your data, there's {{ 'pages.whatIsToju.benefits.privacyArchitecture.description' | translate }}
nothing to hack, subpoena, or sell. Your privacy isn't protected by a promise; it's protected by how the technology works.
</p> </p>
</div> </div>
@@ -135,10 +120,9 @@
/> />
</svg> </svg>
</div> </div>
<h3 class="text-lg font-semibold text-foreground mb-3">Performance Without Compromise</h3> <h3 class="text-lg font-semibold text-foreground mb-3">{{ 'pages.whatIsToju.benefits.performance.title' | translate }}</h3>
<p class="text-muted-foreground leading-relaxed text-sm"> <p class="text-muted-foreground leading-relaxed text-sm">
Direct connections mean lower latency. Your voice reaches your friend faster. Your screen share is smoother. Your file arrives in the time {{ 'pages.whatIsToju.benefits.performance.description' | translate }}
it actually takes to transfer - not in the time it takes to upload, store, then download.
</p> </p>
</div> </div>
@@ -158,11 +142,9 @@
/> />
</svg> </svg>
</div> </div>
<h3 class="text-lg font-semibold text-foreground mb-3">Sustainable &amp; Free</h3> <h3 class="text-lg font-semibold text-foreground mb-3">{{ 'pages.whatIsToju.benefits.sustainable.title' | translate }}</h3>
<p class="text-muted-foreground leading-relaxed text-sm"> <p class="text-muted-foreground leading-relaxed text-sm">
Running a traditional chat service with millions of users costs enormous amounts of money for servers. That cost gets passed to you. With {{ 'pages.whatIsToju.benefits.sustainable.description' | translate }}
peer-to-peer, the only infrastructure we run is a tiny coordination server - costing almost nothing. That's how we keep it free
permanently.
</p> </p>
</div> </div>
@@ -182,10 +164,9 @@
/> />
</svg> </svg>
</div> </div>
<h3 class="text-lg font-semibold text-foreground mb-3">Independence &amp; Freedom</h3> <h3 class="text-lg font-semibold text-foreground mb-3">{{ 'pages.whatIsToju.benefits.independence.title' | translate }}</h3>
<p class="text-muted-foreground leading-relaxed text-sm"> <p class="text-muted-foreground leading-relaxed text-sm">
You're not locked into our ecosystem. The code is open source. You can run your own server. If we ever disappeared tomorrow, you could {{ 'pages.whatIsToju.benefits.independence.description' | translate }}
still use Toju. Your communication tools should belong to you, not a corporation.
</p> </p>
</div> </div>
</div> </div>
@@ -195,38 +176,34 @@
<!-- FAQ-style section --> <!-- FAQ-style section -->
<section class="container mx-auto px-6 mb-24"> <section class="container mx-auto px-6 mb-24">
<div class="max-w-3xl mx-auto section-fade"> <div class="max-w-3xl mx-auto section-fade">
<h2 class="text-3xl md:text-4xl font-bold text-foreground mb-12 text-center">Common <span class="gradient-text">Questions</span></h2> <h2 class="text-3xl md:text-4xl font-bold text-foreground mb-12 text-center">{{ 'pages.whatIsToju.faq.titlePrefix' | translate }} <span class="gradient-text">{{ 'pages.whatIsToju.faq.titleHighlight' | translate }}</span></h2>
<div class="space-y-6"> <div class="space-y-6">
<div class="rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-6 md:p-8"> <div class="rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-6 md:p-8">
<h3 class="text-lg font-semibold text-foreground mb-2">Is Toju really free? What's the catch?</h3> <h3 class="text-lg font-semibold text-foreground mb-2">{{ 'pages.whatIsToju.faq.items.free.question' | translate }}</h3>
<p class="text-muted-foreground leading-relaxed text-sm"> <p class="text-muted-foreground leading-relaxed text-sm">
Yes, it's really free. There is no catch. Because Toju uses peer-to-peer connections, we don't need expensive server infrastructure. Our {{ 'pages.whatIsToju.faq.items.free.answer' | translate }}
costs are minimal, and we fund development through community support and donations. Every feature is available to everyone.
</p> </p>
</div> </div>
<div class="rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-6 md:p-8"> <div class="rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-6 md:p-8">
<h3 class="text-lg font-semibold text-foreground mb-2">Do I need technical knowledge to use Toju?</h3> <h3 class="text-lg font-semibold text-foreground mb-2">{{ 'pages.whatIsToju.faq.items.technical.question' | translate }}</h3>
<p class="text-muted-foreground leading-relaxed text-sm"> <p class="text-muted-foreground leading-relaxed text-sm">
Not at all. Toju works like any other chat app - download it, create an account, and start talking. All the peer-to-peer magic happens {{ 'pages.whatIsToju.faq.items.technical.answer' | translate }}
behind the scenes. You just enjoy the benefits of better privacy, performance, and no limits.
</p> </p>
</div> </div>
<div class="rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-6 md:p-8"> <div class="rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-6 md:p-8">
<h3 class="text-lg font-semibold text-foreground mb-2">What does "self-host the signal server" mean?</h3> <h3 class="text-lg font-semibold text-foreground mb-2">{{ 'pages.whatIsToju.faq.items.selfHost.question' | translate }}</h3>
<p class="text-muted-foreground leading-relaxed text-sm"> <p class="text-muted-foreground leading-relaxed text-sm">
The signal server is a tiny program that helps users find each other online. We run one by default, but if you prefer complete control, {{ 'pages.whatIsToju.faq.items.selfHost.answer' | translate }}
you can run your own copy on your own hardware. It's like having your own private phone directory - only people you invite can use it.
</p> </p>
</div> </div>
<div class="rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-6 md:p-8"> <div class="rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-6 md:p-8">
<h3 class="text-lg font-semibold text-foreground mb-2">Is my data safe?</h3> <h3 class="text-lg font-semibold text-foreground mb-2">{{ 'pages.whatIsToju.faq.items.safe.question' | translate }}</h3>
<p class="text-muted-foreground leading-relaxed text-sm"> <p class="text-muted-foreground leading-relaxed text-sm">
Your conversations, files, and calls go directly between you and the person you're talking to. They never pass through or get stored on {{ 'pages.whatIsToju.faq.items.safe.answer' | translate }}
our servers. Even if someone broke into our server, there would be nothing to find - because we never had your data in the first place.
</p> </p>
</div> </div>
</div> </div>
@@ -238,14 +215,14 @@
<div <div
class="section-fade max-w-2xl mx-auto text-center rounded-2xl border border-purple-500/20 bg-gradient-to-br from-purple-950/20 to-violet-950/20 p-12" class="section-fade max-w-2xl mx-auto text-center rounded-2xl border border-purple-500/20 bg-gradient-to-br from-purple-950/20 to-violet-950/20 p-12"
> >
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-4">Ready to try it?</h2> <h2 class="text-2xl md:text-3xl font-bold text-foreground mb-4">{{ 'pages.whatIsToju.cta.title' | translate }}</h2>
<p class="text-muted-foreground mb-8">Available on Windows, Linux, and in your browser. Always free.</p> <p class="text-muted-foreground mb-8">{{ 'pages.whatIsToju.cta.description' | translate }}</p>
<div class="flex flex-col sm:flex-row items-center justify-center gap-4"> <div class="flex flex-col sm:flex-row items-center justify-center gap-4">
<a <a
routerLink="/downloads" routerLink="/downloads"
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold hover:from-purple-500 hover:to-violet-500 transition-all shadow-lg shadow-purple-500/25" class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold hover:from-purple-500 hover:to-violet-500 transition-all shadow-lg shadow-purple-500/25"
> >
Download Toju {{ 'common.actions.downloadBrand' | translate }}
</a> </a>
<a <a
href="https://web.toju.app/" href="https://web.toju.app/"
@@ -253,7 +230,7 @@
rel="noopener" rel="noopener"
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium hover:border-purple-500/30 transition-all" class="inline-flex items-center gap-2 px-6 py-3 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium hover:border-purple-500/30 transition-all"
> >
Open in Browser {{ 'common.actions.openInBrowser' | translate }}
</a> </a>
</div> </div>
</div> </div>

View File

@@ -7,6 +7,7 @@ import {
PLATFORM_ID PLATFORM_ID
} from '@angular/core'; } from '@angular/core';
import { isPlatformBrowser } from '@angular/common'; import { isPlatformBrowser } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { SeoService } from '../../services/seo.service'; import { SeoService } from '../../services/seo.service';
import { ScrollAnimationService } from '../../services/scroll-animation.service'; import { ScrollAnimationService } from '../../services/scroll-animation.service';
@@ -15,7 +16,11 @@ import { AdSlotComponent } from '../../components/ad-slot/ad-slot.component';
@Component({ @Component({
selector: 'app-what-is-toju', selector: 'app-what-is-toju',
standalone: true, standalone: true,
imports: [RouterLink, AdSlotComponent], imports: [
RouterLink,
AdSlotComponent,
TranslateModule
],
templateUrl: './what-is-toju.component.html' templateUrl: './what-is-toju.component.html'
}) })
export class WhatIsTojuComponent implements OnInit, AfterViewInit, OnDestroy { export class WhatIsTojuComponent implements OnInit, AfterViewInit, OnDestroy {
@@ -24,11 +29,7 @@ export class WhatIsTojuComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly platformId = inject(PLATFORM_ID); private readonly platformId = inject(PLATFORM_ID);
ngOnInit(): void { ngOnInit(): void {
this.seoService.update({ this.seoService.updateFromTranslations('pages.whatIsToju.seo', {
title: 'What is Toju? - How It Works',
description:
'Learn how Toju\'s peer-to-peer technology delivers free, private, unlimited voice calls, screen sharing, and '
+ 'file transfers without centralized servers.',
url: 'https://toju.app/what-is-toju' url: 'https://toju.app/what-is-toju'
}); });
} }

View File

@@ -20,8 +20,10 @@ export interface Release {
html_url: string; html_url: string;
} }
export type OsKey = 'windows' | 'macos' | 'linux' | 'linuxDebian' | 'archive' | 'web' | 'other';
export interface DetectedOS { export interface DetectedOS {
name: string; key: 'windows' | 'macos' | 'linux' | 'linuxDebian';
icon: string | null; icon: string | null;
filePattern: RegExp; filePattern: RegExp;
ymlFile: string; ymlFile: string;
@@ -78,7 +80,7 @@ export class ReleaseService {
detectOS(): DetectedOS { detectOS(): DetectedOS {
if (!isPlatformBrowser(this.platformId)) { if (!isPlatformBrowser(this.platformId)) {
return { return {
name: 'Linux', key: 'linux',
icon: null, icon: null,
filePattern: /\.AppImage$/i, filePattern: /\.AppImage$/i,
ymlFile: 'latest-linux.yml' ymlFile: 'latest-linux.yml'
@@ -89,7 +91,7 @@ export class ReleaseService {
if (userAgent.includes('win')) { if (userAgent.includes('win')) {
return { return {
name: 'Windows', key: 'windows',
icon: null, icon: null,
filePattern: /\.exe$/i, filePattern: /\.exe$/i,
ymlFile: 'latest.yml' ymlFile: 'latest.yml'
@@ -98,7 +100,7 @@ export class ReleaseService {
if (userAgent.includes('mac')) { if (userAgent.includes('mac')) {
return { return {
name: 'macOS', key: 'macos',
icon: null, icon: null,
filePattern: /\.dmg$/i, filePattern: /\.dmg$/i,
ymlFile: 'latest-mac.yml' ymlFile: 'latest-mac.yml'
@@ -109,7 +111,7 @@ export class ReleaseService {
if (isUbuntuDebian) { if (isUbuntuDebian) {
return { return {
name: 'Linux (deb)', key: 'linuxDebian',
icon: null, icon: null,
filePattern: /\.deb$/i, filePattern: /\.deb$/i,
ymlFile: 'latest-linux.yml' ymlFile: 'latest-linux.yml'
@@ -117,7 +119,7 @@ export class ReleaseService {
} }
return { return {
name: 'Linux', key: 'linux',
icon: null, icon: null,
filePattern: /\.AppImage$/i, filePattern: /\.AppImage$/i,
ymlFile: 'latest-linux.yml' ymlFile: 'latest-linux.yml'
@@ -162,30 +164,30 @@ export class ReleaseService {
return matchingAsset?.browser_download_url ?? null; return matchingAsset?.browser_download_url ?? null;
} }
getAssetOS(name: string): string { getAssetOSKey(name: string): OsKey {
const lower = name.toLowerCase(); const lower = name.toLowerCase();
if (matchesAssetPattern(lower, WINDOWS_SUFFIXES, WINDOWS_HINTS)) { if (matchesAssetPattern(lower, WINDOWS_SUFFIXES, WINDOWS_HINTS)) {
return 'Windows'; return 'windows';
} }
if (matchesAssetPattern(lower, MAC_SUFFIXES, MAC_HINTS)) { if (matchesAssetPattern(lower, MAC_SUFFIXES, MAC_HINTS)) {
return 'macOS'; return 'macos';
} }
if (matchesAssetPattern(lower, LINUX_SUFFIXES, LINUX_HINTS)) { if (matchesAssetPattern(lower, LINUX_SUFFIXES, LINUX_HINTS)) {
return 'Linux'; return 'linux';
} }
if (matchesAssetPattern(lower, ARCHIVE_SUFFIXES)) { if (matchesAssetPattern(lower, ARCHIVE_SUFFIXES)) {
return 'Archive'; return 'archive';
} }
if (lower.endsWith('.wasm')) { if (lower.endsWith('.wasm')) {
return 'Web'; return 'web';
} }
return 'Other'; return 'other';
} }
formatBytes(bytes: number): string { formatBytes(bytes: number): string {
@@ -246,15 +248,6 @@ export class ReleaseService {
} }
private getReleaseEndpoints(): string[] { private getReleaseEndpoints(): string[] {
if (!isPlatformBrowser(this.platformId)) {
return [PROXY_RELEASES_API_URL, DIRECT_RELEASES_API_URL]; return [PROXY_RELEASES_API_URL, DIRECT_RELEASES_API_URL];
} }
const hostname = window.location.hostname;
const isLocalHost = hostname === 'localhost' || hostname === '127.0.0.1';
return isLocalHost
? [PROXY_RELEASES_API_URL, DIRECT_RELEASES_API_URL]
: [DIRECT_RELEASES_API_URL, PROXY_RELEASES_API_URL];
}
} }

View File

@@ -1,4 +1,5 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { Meta, Title } from '@angular/platform-browser'; import { Meta, Title } from '@angular/platform-browser';
interface SeoData { interface SeoData {
@@ -12,6 +13,7 @@ interface SeoData {
export class SeoService { export class SeoService {
private readonly meta = inject(Meta); private readonly meta = inject(Meta);
private readonly title = inject(Title); private readonly title = inject(Title);
private readonly translate = inject(TranslateService);
update(data: SeoData): void { update(data: SeoData): void {
const fullTitle = `${data.title} - Toju`; const fullTitle = `${data.title} - Toju`;
@@ -34,4 +36,12 @@ export class SeoService {
this.meta.updateTag({ name: 'twitter:image', content: data.image }); this.meta.updateTag({ name: 'twitter:image', content: data.image });
} }
} }
updateFromTranslations(keyPrefix: string, options: Omit<SeoData, 'title' | 'description'> = {}): void {
this.update({
title: this.translate.instant(`${keyPrefix}.title`),
description: this.translate.instant(`${keyPrefix}.description`),
...options
});
}
} }

View File

@@ -13,7 +13,16 @@ const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser'); const browserDistFolder = resolve(serverDistFolder, '../browser');
const indexHtml = join(serverDistFolder, 'index.server.html'); const indexHtml = join(serverDistFolder, 'index.server.html');
const app = express(); const app = express();
const commonEngine = new CommonEngine(); const commonEngine = new CommonEngine({
allowedHosts: [
'toju.app',
'www.toju.app',
'localhost',
'127.0.0.1'
]
});
app.set('trust proxy', 'loopback');
/** /**
* Proxy endpoint for Gitea releases API to avoid CORS issues. * Proxy endpoint for Gitea releases API to avoid CORS issues.
@@ -51,7 +60,8 @@ app.get(
'**', '**',
express.static(browserDistFolder, { express.static(browserDistFolder, {
maxAge: '1y', maxAge: '1y',
index: 'index.html' index: 'index.html',
redirect: false
}) })
); );

View File

@@ -14,6 +14,7 @@
"src/server.ts" "src/server.ts"
], ],
"include": [ "include": [
"src/**/*.d.ts" "src/**/*.d.ts",
"public/i18n/**/*.json"
] ]
} }

View File

@@ -14,6 +14,7 @@
"esModuleInterop": true, "esModuleInterop": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true,
"importHelpers": true, "importHelpers": true,
"target": "ES2022", "target": "ES2022",
"module": "ES2022" "module": "ES2022"

View File

@@ -10,6 +10,7 @@
}, },
"include": [ "include": [
"src/**/*.spec.ts", "src/**/*.spec.ts",
"src/**/*.d.ts" "src/**/*.d.ts",
"public/i18n/**/*.json"
] ]
} }