Compare commits
10 Commits
v1.0.32
...
2b6e477c9a
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b6e477c9a | |||
| 22d355a522 | |||
| 15c5952e29 | |||
| 781c05294f | |||
| 778e75bef5 | |||
| 7bf37ba510 | |||
| 3c04b5db26 | |||
| 45e0b09af8 | |||
| 106212ef3d | |||
| be465fd297 |
43
.gitea/workflows/deploy-web-apps.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: Deploy Web Apps
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: windows
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: powershell
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install root dependencies
|
||||||
|
env:
|
||||||
|
NODE_ENV: development
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install website dependencies
|
||||||
|
env:
|
||||||
|
NODE_ENV: development
|
||||||
|
run: npm ci --prefix website
|
||||||
|
|
||||||
|
- name: Build Toju web app
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Build Toju website
|
||||||
|
run: |
|
||||||
|
Push-Location website
|
||||||
|
npm run build
|
||||||
|
Pop-Location
|
||||||
|
|
||||||
|
- name: Deploy both apps to IIS
|
||||||
|
run: >
|
||||||
|
./tools/deploy-web-apps.ps1
|
||||||
|
-WebsitePort 4341
|
||||||
|
-AppPort 4492
|
||||||
@@ -3,7 +3,6 @@ import { readDesktopSettings } from '../desktop-settings';
|
|||||||
|
|
||||||
export function configureAppFlags(): void {
|
export function configureAppFlags(): void {
|
||||||
linuxSpecificFlags();
|
linuxSpecificFlags();
|
||||||
audioFlags();
|
|
||||||
networkFlags();
|
networkFlags();
|
||||||
setupGpuEncodingFlags();
|
setupGpuEncodingFlags();
|
||||||
chromiumFlags();
|
chromiumFlags();
|
||||||
@@ -15,21 +14,40 @@ function chromiumFlags(): void {
|
|||||||
|
|
||||||
// Suppress Autofill devtools errors
|
// Suppress Autofill devtools errors
|
||||||
app.commandLine.appendSwitch('disable-features', 'Autofill,AutofillAssistant,AutofillServerCommunication');
|
app.commandLine.appendSwitch('disable-features', 'Autofill,AutofillAssistant,AutofillServerCommunication');
|
||||||
|
|
||||||
|
// Collect all enabled features into a single switch to avoid later calls overwriting earlier ones
|
||||||
|
const enabledFeatures: string[] = [];
|
||||||
|
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
// PipeWire-based audio pipeline for screen share audio capture
|
||||||
|
enabledFeatures.push('AudioServiceOutOfProcess');
|
||||||
|
// PipeWire-based screen capture so the xdg-desktop-portal system picker works
|
||||||
|
enabledFeatures.push('WebRTCPipeWireCapturer');
|
||||||
}
|
}
|
||||||
|
|
||||||
function audioFlags(): void {
|
const desktopSettings = readDesktopSettings();
|
||||||
if (process.platform === 'linux') {
|
|
||||||
// Use the new PipeWire-based audio pipeline on Linux for better screen share audio capture support
|
if (process.platform === 'linux' && desktopSettings.vaapiVideoEncode) {
|
||||||
app.commandLine.appendSwitch('enable-features', 'AudioServiceOutOfProcess');
|
enabledFeatures.push('VaapiVideoEncode');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabledFeatures.length > 0) {
|
||||||
|
app.commandLine.appendSwitch('enable-features', enabledFeatures.join(','));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function linuxSpecificFlags(): void {
|
function linuxSpecificFlags(): void {
|
||||||
|
if (process.platform !== 'linux') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Disable sandbox on Linux to avoid SUID / /tmp shared-memory issues
|
// Disable sandbox on Linux to avoid SUID / /tmp shared-memory issues
|
||||||
if (process.platform === 'linux') {
|
|
||||||
app.commandLine.appendSwitch('no-sandbox');
|
app.commandLine.appendSwitch('no-sandbox');
|
||||||
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
||||||
}
|
|
||||||
|
// Auto-detect Wayland vs X11 so the xdg-desktop-portal system picker
|
||||||
|
// works for screen capture on Wayland compositors
|
||||||
|
app.commandLine.appendSwitch('ozone-platform-hint', 'auto');
|
||||||
}
|
}
|
||||||
|
|
||||||
function networkFlags(): void {
|
function networkFlags(): void {
|
||||||
@@ -46,11 +64,6 @@ function setupGpuEncodingFlags(): void {
|
|||||||
app.disableHardwareAcceleration();
|
app.disableHardwareAcceleration();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.platform === 'linux' && desktopSettings.vaapiVideoEncode) {
|
|
||||||
// Enable VA-API hardware video encoding on Linux
|
|
||||||
app.commandLine.appendSwitch('enable-features', 'VaapiVideoEncode');
|
|
||||||
}
|
|
||||||
|
|
||||||
app.commandLine.appendSwitch('enable-gpu-rasterization');
|
app.commandLine.appendSwitch('enable-gpu-rasterization');
|
||||||
app.commandLine.appendSwitch('enable-zero-copy');
|
app.commandLine.appendSwitch('enable-zero-copy');
|
||||||
app.commandLine.appendSwitch('enable-native-gpu-memory-buffers');
|
app.commandLine.appendSwitch('enable-native-gpu-memory-buffers');
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ export function setupSystemHandlers(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-sources', async () => {
|
ipcMain.handle('get-sources', async () => {
|
||||||
|
try {
|
||||||
const thumbnailSize = { width: 240, height: 150 };
|
const thumbnailSize = { width: 240, height: 150 };
|
||||||
const [screenSources, windowSources] = await Promise.all([
|
const [screenSources, windowSources] = await Promise.all([
|
||||||
desktopCapturer.getSources({
|
desktopCapturer.getSources({
|
||||||
@@ -224,6 +225,11 @@ export function setupSystemHandlers(): void {
|
|||||||
name: source.name,
|
name: source.name,
|
||||||
thumbnail: source.thumbnail.toDataURL()
|
thumbnail: source.thumbnail.toDataURL()
|
||||||
}));
|
}));
|
||||||
|
} catch {
|
||||||
|
// desktopCapturer.getSources fails on Wayland; return empty so the
|
||||||
|
// renderer falls through to getDisplayMedia with the system picker.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {
|
ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
|
desktopCapturer,
|
||||||
|
session,
|
||||||
shell
|
shell
|
||||||
} from 'electron';
|
} from 'electron';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
@@ -61,6 +63,34 @@ export async function createWindow(): Promise<void> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
session.defaultSession.setDisplayMediaRequestHandler(
|
||||||
|
async (_request, respond) => {
|
||||||
|
// On Linux/Wayland the system picker (useSystemPicker: true) handles
|
||||||
|
// the portal. This handler is only reached if the system picker is
|
||||||
|
// unavailable (e.g. X11 without a portal). Fall back to
|
||||||
|
// desktopCapturer so the user still gets something.
|
||||||
|
try {
|
||||||
|
const sources = await desktopCapturer.getSources({
|
||||||
|
types: ['window', 'screen'],
|
||||||
|
thumbnailSize: { width: 150, height: 150 }
|
||||||
|
});
|
||||||
|
const firstSource = sources[0];
|
||||||
|
|
||||||
|
if (firstSource) {
|
||||||
|
respond({ video: firstSource });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// desktopCapturer also unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
respond({});
|
||||||
|
},
|
||||||
|
{ useSystemPicker: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (process.env['NODE_ENV'] === 'development') {
|
if (process.env['NODE_ENV'] === 'development') {
|
||||||
const devUrl = process.env['SSL'] === 'true'
|
const devUrl = process.env['SSL'] === 'true'
|
||||||
? 'https://localhost:4200'
|
? 'https://localhost:4200'
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"electron:build:all": "npm run build:prod && npm run build:electron && electron-builder --win --mac --linux",
|
"electron:build:all": "npm run build:prod && npm run build:electron && electron-builder --win --mac --linux",
|
||||||
"build:prod:all": "npm run build:prod && npm run build:electron && cd server && npm run build",
|
"build:prod:all": "npm run build:prod && npm run build:electron && cd server && npm run build",
|
||||||
"build:prod:win": "npm run build:prod:all && electron-builder --win",
|
"build:prod:win": "npm run build:prod:all && electron-builder --win",
|
||||||
"dev": "npm run electron:full",
|
"dev": "npm run build:electron && npm run electron:full",
|
||||||
"dev:app": "npm run electron:dev",
|
"dev:app": "npm run electron:dev",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "npm run format && npm run sort:props && eslint . --fix",
|
"lint:fix": "npm run format && npm run sort:props && eslint . --fix",
|
||||||
|
|||||||
23
public/web.config
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<system.webServer>
|
||||||
|
<staticContent>
|
||||||
|
<remove fileExtension=".wasm" />
|
||||||
|
<remove fileExtension=".webmanifest" />
|
||||||
|
<mimeMap fileExtension=".wasm" mimeType="application/wasm" />
|
||||||
|
<mimeMap fileExtension=".webmanifest" mimeType="application/manifest+json" />
|
||||||
|
</staticContent>
|
||||||
|
<rewrite>
|
||||||
|
<rules>
|
||||||
|
<rule name="Angular Routes" stopProcessing="true">
|
||||||
|
<match url=".*" />
|
||||||
|
<conditions logicalGrouping="MatchAll">
|
||||||
|
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
|
||||||
|
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
|
||||||
|
</conditions>
|
||||||
|
<action type="Rewrite" url="/index.html" />
|
||||||
|
</rule>
|
||||||
|
</rules>
|
||||||
|
</rewrite>
|
||||||
|
</system.webServer>
|
||||||
|
</configuration>
|
||||||
@@ -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 }));
|
||||||
|
|||||||
@@ -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() }));
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ function buildDefaultServerUrl(): string {
|
|||||||
|
|
||||||
/** Blueprint for the built-in default endpoint. */
|
/** Blueprint for the built-in default endpoint. */
|
||||||
const DEFAULT_ENDPOINT: Omit<ServerEndpoint, 'id'> = {
|
const DEFAULT_ENDPOINT: Omit<ServerEndpoint, 'id'> = {
|
||||||
name: 'Local Server',
|
name: 'Default Server',
|
||||||
url: buildDefaultServerUrl(),
|
url: buildDefaultServerUrl(),
|
||||||
isActive: true,
|
isActive: true,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 }))
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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,53 +144,64 @@ 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.logger.warn('Linux Electron audio routing failed; falling back to standard capture', error);
|
this.logger.warn('Linux Electron audio routing failed; falling back to standard capture', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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.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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
captureMethod = 'electron-desktop';
|
captureMethod = 'electron-desktop';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.isScreenShareSelectionAborted(error)) {
|
this.rethrowIfScreenShareAborted(error);
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.warn('Electron desktop capture failed; falling back to getDisplayMedia', error);
|
this.logger.warn('Electron desktop capture failed; falling back to getDisplayMedia', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,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;
|
||||||
@@ -391,18 +330,6 @@ export class ScreenShareManager {
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private isElectronDesktopCaptureAvailable(): boolean {
|
|
||||||
return !!this.getElectronApi()?.getSources;
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
@@ -413,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.
|
||||||
*
|
*
|
||||||
@@ -548,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 });
|
||||||
@@ -578,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));
|
||||||
@@ -621,534 +508,17 @@ 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 && error.name === 'AbortError';
|
return error instanceof Error
|
||||||
|
&& (error.name === 'AbortError' || error.name === 'NotAllowedError');
|
||||||
}
|
}
|
||||||
|
|
||||||
private isLinuxElectronAudioRoutingSupported(): boolean {
|
private rethrowIfScreenShareAborted(error: unknown): void {
|
||||||
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
if (this.isScreenShareSelectionAborted(error)) {
|
||||||
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.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const desktopCapture = await this.startWithElectronDesktopCapturer({
|
|
||||||
...options,
|
|
||||||
includeSystemAudio: false
|
|
||||||
}, preset);
|
|
||||||
|
|
||||||
desktopStream = desktopCapture.stream;
|
|
||||||
|
|
||||||
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;
|
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];
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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 */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
|||||||
@@ -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()"
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: true,
|
production: true,
|
||||||
defaultServerUrl: 'https://tojusignal.azaaxin.com'
|
defaultServerUrl: 'https://signal.toju.app'
|
||||||
};
|
};
|
||||||
|
|||||||
105
tools/deploy-web-apps.ps1
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$RepoRoot = (Split-Path -Parent $PSScriptRoot),
|
||||||
|
[string]$IisRoot = 'C:\inetpub\wwwroot',
|
||||||
|
[int]$WebsitePort = 4341,
|
||||||
|
[int]$AppPort = 4492
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
try {
|
||||||
|
Import-Module WebAdministration -ErrorAction Stop
|
||||||
|
} catch {
|
||||||
|
throw 'The IIS WebAdministration module is required on the Windows runner.'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-RoboCopyMirror {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Source,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Destination
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $Source)) {
|
||||||
|
throw "Build output not found: $Source"
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $Destination -Force | Out-Null
|
||||||
|
robocopy $Source $Destination /MIR /NFL /NDL /NJH /NJS /NP | Out-Null
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -gt 7) {
|
||||||
|
throw "robocopy failed from $Source to $Destination with exit code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
|
||||||
|
$global:LASTEXITCODE = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function Ensure-AppPool {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Name
|
||||||
|
)
|
||||||
|
|
||||||
|
$appPoolPath = "IIS:\AppPools\$Name"
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $appPoolPath)) {
|
||||||
|
New-WebAppPool -Name $Name | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-ItemProperty $appPoolPath -Name managedRuntimeVersion -Value ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function Publish-IisSite {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$SiteName,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$SourcePath,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$DestinationPath,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[int]$Port
|
||||||
|
)
|
||||||
|
|
||||||
|
Ensure-AppPool -Name $SiteName
|
||||||
|
Invoke-RoboCopyMirror -Source $SourcePath -Destination $DestinationPath
|
||||||
|
|
||||||
|
$existingSite = Get-Website -Name $SiteName -ErrorAction SilentlyContinue
|
||||||
|
if ($null -ne $existingSite) {
|
||||||
|
Stop-Website -Name $SiteName -ErrorAction SilentlyContinue
|
||||||
|
Remove-Website -Name $SiteName
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Website -Name $SiteName -PhysicalPath $DestinationPath -Port $Port -ApplicationPool $SiteName | Out-Null
|
||||||
|
Start-Website -Name $SiteName
|
||||||
|
|
||||||
|
Write-Host "Deployed $SiteName to $DestinationPath on port $Port."
|
||||||
|
}
|
||||||
|
|
||||||
|
$deployments = @(
|
||||||
|
@{
|
||||||
|
SiteName = 'toju-website'
|
||||||
|
SourcePath = (Join-Path $RepoRoot 'website\dist\toju-website\browser')
|
||||||
|
DestinationPath = (Join-Path $IisRoot 'toju-website')
|
||||||
|
Port = $WebsitePort
|
||||||
|
},
|
||||||
|
@{
|
||||||
|
SiteName = 'toju-app'
|
||||||
|
SourcePath = (Join-Path $RepoRoot 'dist\client\browser')
|
||||||
|
DestinationPath = (Join-Path $IisRoot 'toju-app')
|
||||||
|
Port = $AppPort
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($deployment in $deployments) {
|
||||||
|
Publish-IisSite @deployment
|
||||||
|
}
|
||||||
|
|
||||||
|
$global:LASTEXITCODE = 0
|
||||||
17
website/.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Editor configuration, see https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.ts]
|
||||||
|
quote_type = single
|
||||||
|
ij_typescript_use_double_quotes = false
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off
|
||||||
|
trim_trailing_whitespace = false
|
||||||
42
website/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||||
|
|
||||||
|
# Compiled output
|
||||||
|
/dist
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
|
# Node
|
||||||
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.idea/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/.angular/cache
|
||||||
|
.sass-cache/
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
|
||||||
|
# System files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
4
website/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||||
|
"recommendations": ["angular.ng-template"]
|
||||||
|
}
|
||||||
20
website/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "ng serve",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "npm: start",
|
||||||
|
"url": "http://localhost:4200/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ng test",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "npm: test",
|
||||||
|
"url": "http://localhost:9876/debug.html"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
42
website/.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "start",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": {
|
||||||
|
"owner": "typescript",
|
||||||
|
"pattern": "$tsc",
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": {
|
||||||
|
"regexp": "(.*?)"
|
||||||
|
},
|
||||||
|
"endsPattern": {
|
||||||
|
"regexp": "bundle generation complete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "test",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": {
|
||||||
|
"owner": "typescript",
|
||||||
|
"pattern": "$tsc",
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": {
|
||||||
|
"regexp": "(.*?)"
|
||||||
|
},
|
||||||
|
"endsPattern": {
|
||||||
|
"regexp": "bundle generation complete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
59
website/README.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# TojuWebsite
|
||||||
|
|
||||||
|
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.2.21.
|
||||||
|
|
||||||
|
## Development server
|
||||||
|
|
||||||
|
To start a local development server, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||||
|
|
||||||
|
## Code scaffolding
|
||||||
|
|
||||||
|
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate component component-name
|
||||||
|
```
|
||||||
|
|
||||||
|
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To build the project run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng build
|
||||||
|
```
|
||||||
|
|
||||||
|
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running end-to-end tests
|
||||||
|
|
||||||
|
For end-to-end (e2e) testing, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||||
153
website/angular.json
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"toju-website": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {
|
||||||
|
"@schematics/angular:component": {
|
||||||
|
"style": "scss",
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:class": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:directive": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:guard": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:interceptor": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:pipe": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:resolver": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:service": {
|
||||||
|
"skipTests": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular-devkit/build-angular:application",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/toju-website",
|
||||||
|
"index": "src/index.html",
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"polyfills": [
|
||||||
|
"zone.js"
|
||||||
|
],
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "src/images",
|
||||||
|
"output": "/images"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/styles.scss"
|
||||||
|
],
|
||||||
|
"scripts": [],
|
||||||
|
"server": "src/main.server.ts",
|
||||||
|
"security": {
|
||||||
|
"allowedHosts": [
|
||||||
|
"toju.app",
|
||||||
|
"www.toju.app",
|
||||||
|
"localhost",
|
||||||
|
"127.0.0.1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"prerender": true,
|
||||||
|
"ssr": {
|
||||||
|
"entry": "src/server.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "1MB",
|
||||||
|
"maximumError": "2MB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "8kB",
|
||||||
|
"maximumError": "16kB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"options": {
|
||||||
|
"proxyConfig": "proxy.conf.json"
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "toju-website:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "toju-website:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"extract-i18n": {
|
||||||
|
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
|
"options": {
|
||||||
|
"polyfills": [
|
||||||
|
"zone.js",
|
||||||
|
"zone.js/testing"
|
||||||
|
],
|
||||||
|
"tsConfig": "tsconfig.spec.json",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "src/images",
|
||||||
|
"output": "/images/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/styles.scss"
|
||||||
|
],
|
||||||
|
"scripts": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cli": {
|
||||||
|
"analytics": false
|
||||||
|
}
|
||||||
|
}
|
||||||
15702
website/package-lock.json
generated
Normal file
50
website/package.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "toju-website",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test",
|
||||||
|
"serve:ssr:toju-website": "node dist/toju-website/server/server.mjs"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/common": "^19.2.0",
|
||||||
|
"@angular/compiler": "^19.2.0",
|
||||||
|
"@angular/core": "^19.2.0",
|
||||||
|
"@angular/forms": "^19.2.0",
|
||||||
|
"@angular/platform-browser": "^19.2.0",
|
||||||
|
"@angular/platform-browser-dynamic": "^19.2.0",
|
||||||
|
"@angular/platform-server": "^19.2.0",
|
||||||
|
"@angular/router": "^19.2.0",
|
||||||
|
"@angular/ssr": "^19.2.21",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@tsparticles/angular": "^3.0.0",
|
||||||
|
"@tsparticles/engine": "^3.9.1",
|
||||||
|
"@tsparticles/slim": "^3.9.1",
|
||||||
|
"autoprefixer": "^10.4.27",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"postcss": "^8.5.8",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"tslib": "^2.3.0",
|
||||||
|
"zone.js": "~0.15.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular-devkit/build-angular": "^19.2.21",
|
||||||
|
"@angular/cli": "^19.2.21",
|
||||||
|
"@angular/compiler-cli": "^19.2.0",
|
||||||
|
"@types/express": "^4.17.17",
|
||||||
|
"@types/jasmine": "~5.1.0",
|
||||||
|
"@types/node": "^18.18.0",
|
||||||
|
"jasmine-core": "~5.6.0",
|
||||||
|
"karma": "~6.4.0",
|
||||||
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
|
"karma-coverage": "~2.2.0",
|
||||||
|
"karma-jasmine": "~5.1.0",
|
||||||
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
|
"typescript": "~5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
website/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
10
website/proxy.conf.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"/api/releases": {
|
||||||
|
"target": "https://git.azaaxin.com",
|
||||||
|
"secure": true,
|
||||||
|
"changeOrigin": true,
|
||||||
|
"pathRewrite": {
|
||||||
|
"^/api/releases": "/api/v1/repos/myxelium/Toju/releases"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
website/public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
website/public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 226 KiB |
BIN
website/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
website/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 896 B |
BIN
website/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
website/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
website/public/iconsan.png
Normal file
|
After Width: | Height: | Size: 308 KiB |
BIN
website/public/og-image.png
Normal file
|
After Width: | Height: | Size: 406 KiB |
4
website/public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://toju.app/sitemap.xml
|
||||||
1
website/public/site.webmanifest
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
27
website/public/sitemap.xml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://toju.app/</loc>
|
||||||
|
<lastmod>2026-03-12</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://toju.app/what-is-toju</loc>
|
||||||
|
<lastmod>2026-03-12</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://toju.app/downloads</loc>
|
||||||
|
<lastmod>2026-03-12</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://toju.app/philosophy</loc>
|
||||||
|
<lastmod>2026-03-12</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
23
website/public/web.config
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<system.webServer>
|
||||||
|
<staticContent>
|
||||||
|
<remove fileExtension=".wasm" />
|
||||||
|
<remove fileExtension=".webmanifest" />
|
||||||
|
<mimeMap fileExtension=".wasm" mimeType="application/wasm" />
|
||||||
|
<mimeMap fileExtension=".webmanifest" mimeType="application/manifest+json" />
|
||||||
|
</staticContent>
|
||||||
|
<rewrite>
|
||||||
|
<rules>
|
||||||
|
<rule name="Angular Routes" stopProcessing="true">
|
||||||
|
<match url=".*" />
|
||||||
|
<conditions logicalGrouping="MatchAll">
|
||||||
|
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
|
||||||
|
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
|
||||||
|
</conditions>
|
||||||
|
<action type="Rewrite" url="/index.html" />
|
||||||
|
</rule>
|
||||||
|
</rules>
|
||||||
|
</rewrite>
|
||||||
|
</system.webServer>
|
||||||
|
</configuration>
|
||||||
8
website/src/app/app.component.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<app-particle-bg />
|
||||||
|
<div class="relative z-10 flex min-h-screen flex-col">
|
||||||
|
<app-header />
|
||||||
|
<main class="flex-1">
|
||||||
|
<router-outlet />
|
||||||
|
</main>
|
||||||
|
<app-footer />
|
||||||
|
</div>
|
||||||
3
website/src/app/app.component.scss
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
20
website/src/app/app.component.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
import { HeaderComponent } from './components/header/header.component';
|
||||||
|
import { FooterComponent } from './components/footer/footer.component';
|
||||||
|
import { ParticleBgComponent } from './components/particle-bg/particle-bg.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
RouterOutlet,
|
||||||
|
HeaderComponent,
|
||||||
|
FooterComponent,
|
||||||
|
ParticleBgComponent
|
||||||
|
],
|
||||||
|
templateUrl: './app.component.html',
|
||||||
|
styleUrl: './app.component.scss'
|
||||||
|
})
|
||||||
|
export class AppComponent {}
|
||||||
|
|
||||||
9
website/src/app/app.config.server.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { ApplicationConfig, mergeApplicationConfig } from '@angular/core';
|
||||||
|
import { provideServerRendering } from '@angular/platform-server';
|
||||||
|
import { appConfig } from './app.config';
|
||||||
|
|
||||||
|
const serverConfig: ApplicationConfig = {
|
||||||
|
providers: [provideServerRendering()]
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config = mergeApplicationConfig(appConfig, serverConfig);
|
||||||
18
website/src/app/app.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||||
|
import { provideRouter, withInMemoryScrolling } from '@angular/router';
|
||||||
|
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
|
||||||
|
import { provideHttpClient, withFetch } from '@angular/common/http';
|
||||||
|
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
|
provideRouter(
|
||||||
|
routes,
|
||||||
|
withInMemoryScrolling({ scrollPositionRestoration: 'top', anchorScrolling: 'enabled' })
|
||||||
|
),
|
||||||
|
provideClientHydration(withEventReplay()),
|
||||||
|
provideHttpClient(withFetch())
|
||||||
|
]
|
||||||
|
};
|
||||||
38
website/src/app/app.routes.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
loadComponent: () => import('./pages/home/home.component').then(
|
||||||
|
(homePageModule) => homePageModule.HomeComponent
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'what-is-toju',
|
||||||
|
loadComponent: () => import('./pages/what-is-toju/what-is-toju.component').then(
|
||||||
|
(whatIsTojuPageModule) => whatIsTojuPageModule.WhatIsTojuComponent
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'downloads',
|
||||||
|
loadComponent: () => import('./pages/downloads/downloads.component').then(
|
||||||
|
(downloadsPageModule) => downloadsPageModule.DownloadsComponent
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'gallery',
|
||||||
|
loadComponent: () => import('./pages/gallery/gallery.component').then(
|
||||||
|
(galleryPageModule) => galleryPageModule.GalleryComponent
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'philosophy',
|
||||||
|
loadComponent: () => import('./pages/philosophy/philosophy.component').then(
|
||||||
|
(philosophyPageModule) => philosophyPageModule.PhilosophyComponent
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '**',
|
||||||
|
redirectTo: ''
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
@if (adService.adsEnabled()) {
|
||||||
|
<div class="container mx-auto px-6 py-4">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Advertisement
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
11
website/src/app/components/ad-slot/ad-slot.component.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { AdService } from '../../services/ad.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-ad-slot',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './ad-slot.component.html'
|
||||||
|
})
|
||||||
|
export class AdSlotComponent {
|
||||||
|
readonly adService = inject(AdService);
|
||||||
|
}
|
||||||
171
website/src/app/components/footer/footer.component.html
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<footer class="border-t border-border/30 bg-background/80 backdrop-blur-sm">
|
||||||
|
<div class="container mx-auto px-6 py-16">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-12">
|
||||||
|
<!-- Brand -->
|
||||||
|
<div class="md:col-span-1">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<img
|
||||||
|
src="/images/toju-logo-transparent.png"
|
||||||
|
alt="Toju"
|
||||||
|
class="h-8 w-auto object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-semibold text-foreground mb-4 uppercase tracking-wider">Product</h4>
|
||||||
|
<ul class="space-y-3">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
routerLink="/downloads"
|
||||||
|
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Downloads
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://web.toju.app/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Web Version
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
routerLink="/what-is-toju"
|
||||||
|
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
What is Toju?
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
routerLink="/gallery"
|
||||||
|
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Image Gallery
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Community -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-semibold text-foreground mb-4 uppercase tracking-wider">Community</h4>
|
||||||
|
<ul class="space-y-3">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://git.azaaxin.com/myxelium/Toju"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/images/gitea.png"
|
||||||
|
alt=""
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
class="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
Source Code
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://github.com/Myxelium"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/images/github.png"
|
||||||
|
alt=""
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
class="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://buymeacoffee.com/myxelium"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/images/buymeacoffee.png"
|
||||||
|
alt=""
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
class="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
Support Us
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Values -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-semibold text-foreground mb-4 uppercase tracking-wider">Values</h4>
|
||||||
|
<ul class="space-y-3">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
routerLink="/philosophy"
|
||||||
|
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Our Philosophy
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li><span class="text-sm text-muted-foreground">100% Free Forever</span></li>
|
||||||
|
<li><span class="text-sm text-muted-foreground">Open Source</span></li>
|
||||||
|
</ul>
|
||||||
|
</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">
|
||||||
|
<p class="text-xs text-muted-foreground">© {{ currentYear }} Myxelium. Toju is open-source software.</p>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<a
|
||||||
|
href="https://git.azaaxin.com/myxelium/Toju"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
aria-label="View source code on Gitea"
|
||||||
|
class="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/images/gitea.png"
|
||||||
|
alt=""
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
class="w-5 h-5 object-contain"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://github.com/Myxelium"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
aria-label="View the project on GitHub"
|
||||||
|
class="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/images/github.png"
|
||||||
|
alt=""
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
class="w-5 h-5 object-contain"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
12
website/src/app/components/footer/footer.component.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-footer',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterLink],
|
||||||
|
templateUrl: './footer.component.html'
|
||||||
|
})
|
||||||
|
export class FooterComponent {
|
||||||
|
readonly currentYear = new Date().getFullYear();
|
||||||
|
}
|
||||||
184
website/src/app/components/header/header.component.html
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<header
|
||||||
|
class="fixed top-0 left-0 right-0 z-50 transition-all duration-300"
|
||||||
|
[class]="scrolled() ? 'glass shadow-lg shadow-black/20' : 'bg-transparent'"
|
||||||
|
>
|
||||||
|
<nav class="container mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
|
<!-- Logo -->
|
||||||
|
<a
|
||||||
|
routerLink="/"
|
||||||
|
aria-label="Toju home"
|
||||||
|
class="flex items-center group"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
<img
|
||||||
|
src="/images/toju-logo-transparent.png"
|
||||||
|
alt="Toju"
|
||||||
|
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>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Beta
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Desktop nav -->
|
||||||
|
<div class="hidden md:flex items-center gap-8">
|
||||||
|
<a
|
||||||
|
routerLink="/"
|
||||||
|
routerLinkActive="text-primary"
|
||||||
|
[routerLinkActiveOptions]="{ exact: true }"
|
||||||
|
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
routerLink="/what-is-toju"
|
||||||
|
routerLinkActive="text-primary"
|
||||||
|
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
What is Toju?
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
routerLink="/downloads"
|
||||||
|
routerLinkActive="text-primary"
|
||||||
|
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Downloads
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
routerLink="/philosophy"
|
||||||
|
routerLinkActive="text-primary"
|
||||||
|
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Our Philosophy
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right side -->
|
||||||
|
<div class="hidden md:flex items-center gap-4">
|
||||||
|
<a
|
||||||
|
href="https://buymeacoffee.com/myxelium"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-sm text-muted-foreground hover:text-yellow-400 transition-colors flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/images/buymeacoffee.png"
|
||||||
|
alt=""
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
class="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
Support Us
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://web.toju.app/"
|
||||||
|
target="_blank"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Use Web Version
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile hamburger -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="md:hidden text-foreground p-2"
|
||||||
|
(click)="mobileOpen.set(!mobileOpen())"
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
@if (mobileOpen()) {
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Mobile nav -->
|
||||||
|
@if (mobileOpen()) {
|
||||||
|
<div
|
||||||
|
class="md:hidden glass border-t border-border/30 px-6 py-4 space-y-4"
|
||||||
|
(click)="mobileOpen.set(false)"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
routerLink="/"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>Home</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
routerLink="/what-is-toju"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>What is Toju?</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
routerLink="/downloads"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>Downloads</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
routerLink="/philosophy"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>Our Philosophy</a
|
||||||
|
>
|
||||||
|
<hr class="border-border/30" />
|
||||||
|
<a
|
||||||
|
href="https://buymeacoffee.com/myxelium"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-yellow-400 transition-colors"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/images/buymeacoffee.png"
|
||||||
|
alt=""
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
class="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
Support Us
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://web.toju.app/"
|
||||||
|
target="_blank"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Use Web Version
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</header>
|
||||||
29
website/src/app/components/header/header.component.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
HostListener,
|
||||||
|
PLATFORM_ID
|
||||||
|
} from '@angular/core';
|
||||||
|
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-header',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterLink, RouterLinkActive],
|
||||||
|
templateUrl: './header.component.html'
|
||||||
|
})
|
||||||
|
export class HeaderComponent {
|
||||||
|
readonly scrolled = signal(false);
|
||||||
|
readonly mobileOpen = signal(false);
|
||||||
|
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
|
||||||
|
@HostListener('window:scroll')
|
||||||
|
onScroll(): void {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
this.scrolled.set(window.scrollY > 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
206
website/src/app/components/particle-bg/particle-bg.component.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
PLATFORM_ID,
|
||||||
|
ViewChild,
|
||||||
|
inject
|
||||||
|
} from '@angular/core';
|
||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-particle-bg',
|
||||||
|
standalone: true,
|
||||||
|
host: {
|
||||||
|
class: 'block fixed inset-0 z-0 pointer-events-none'
|
||||||
|
},
|
||||||
|
template: '<canvas #canvas class="absolute inset-0 h-full w-full pointer-events-auto"></canvas>'
|
||||||
|
})
|
||||||
|
export class ParticleBgComponent implements OnInit, OnDestroy {
|
||||||
|
@ViewChild('canvas', { static: true }) private canvasRef?: ElementRef<HTMLCanvasElement>;
|
||||||
|
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
|
||||||
|
private context: CanvasRenderingContext2D | null = null;
|
||||||
|
private particles: Particle[] = [];
|
||||||
|
private mousePosition = {
|
||||||
|
pointerX: -1000,
|
||||||
|
pointerY: -1000
|
||||||
|
};
|
||||||
|
private animationId = 0;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
if (!isPlatformBrowser(this.platformId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = this.canvasRef?.nativeElement;
|
||||||
|
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.context = context;
|
||||||
|
this.resize();
|
||||||
|
|
||||||
|
window.addEventListener('resize', this.resizeHandler);
|
||||||
|
window.addEventListener('mousemove', this.mouseMoveHandler);
|
||||||
|
|
||||||
|
this.initParticles();
|
||||||
|
this.animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (!isPlatformBrowser(this.platformId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelAnimationFrame(this.animationId);
|
||||||
|
window.removeEventListener('resize', this.resizeHandler);
|
||||||
|
window.removeEventListener('mousemove', this.mouseMoveHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly resizeHandler = () => this.resize();
|
||||||
|
private readonly mouseMoveHandler = (event: MouseEvent) => {
|
||||||
|
this.mousePosition.pointerX = event.clientX;
|
||||||
|
this.mousePosition.pointerY = event.clientY;
|
||||||
|
};
|
||||||
|
|
||||||
|
private resize(): void {
|
||||||
|
const canvas = this.canvasRef?.nativeElement;
|
||||||
|
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
private initParticles(): void {
|
||||||
|
const particleCount = Math.min(
|
||||||
|
80,
|
||||||
|
Math.floor((window.innerWidth * window.innerHeight) / 15000)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.particles = [];
|
||||||
|
|
||||||
|
for (let particleIndex = 0; particleIndex < particleCount; particleIndex++) {
|
||||||
|
this.particles.push({
|
||||||
|
positionX: Math.random() * window.innerWidth,
|
||||||
|
positionY: Math.random() * window.innerHeight,
|
||||||
|
velocityX: (Math.random() - 0.5) * 0.4,
|
||||||
|
velocityY: (Math.random() - 0.5) * 0.4,
|
||||||
|
radius: Math.random() * 2 + 0.5,
|
||||||
|
opacity: Math.random() * 0.5 + 0.1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private animate(): void {
|
||||||
|
const canvas = this.canvasRef?.nativeElement;
|
||||||
|
const context = this.context;
|
||||||
|
|
||||||
|
if (!canvas || !context) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
for (const particle of this.particles) {
|
||||||
|
const deltaX = particle.positionX - this.mousePosition.pointerX;
|
||||||
|
const deltaY = particle.positionY - this.mousePosition.pointerY;
|
||||||
|
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||||
|
|
||||||
|
if (distance < 150 && distance > 0) {
|
||||||
|
const force = (150 - distance) / 150;
|
||||||
|
|
||||||
|
particle.velocityX += (deltaX / distance) * force * 0.3;
|
||||||
|
particle.velocityY += (deltaY / distance) * force * 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
particle.velocityX *= 0.98;
|
||||||
|
particle.velocityY *= 0.98;
|
||||||
|
|
||||||
|
particle.positionX += particle.velocityX;
|
||||||
|
particle.positionY += particle.velocityY;
|
||||||
|
|
||||||
|
if (particle.positionX < 0) {
|
||||||
|
particle.positionX = canvas.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (particle.positionX > canvas.width) {
|
||||||
|
particle.positionX = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (particle.positionY < 0) {
|
||||||
|
particle.positionY = canvas.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (particle.positionY > canvas.height) {
|
||||||
|
particle.positionY = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(particle.positionX, particle.positionY, particle.radius, 0, Math.PI * 2);
|
||||||
|
context.fillStyle = `rgba(139, 92, 246, ${particle.opacity})`;
|
||||||
|
context.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let particleIndex = 0; particleIndex < this.particles.length; particleIndex++) {
|
||||||
|
for (let connectionIndex = particleIndex + 1; connectionIndex < this.particles.length; connectionIndex++) {
|
||||||
|
const sourceParticle = this.particles[particleIndex];
|
||||||
|
const targetParticle = this.particles[connectionIndex];
|
||||||
|
const deltaX = sourceParticle.positionX - targetParticle.positionX;
|
||||||
|
const deltaY = sourceParticle.positionY - targetParticle.positionY;
|
||||||
|
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||||
|
|
||||||
|
if (distance < 120) {
|
||||||
|
const opacity = (1 - distance / 120) * 0.15;
|
||||||
|
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(sourceParticle.positionX, sourceParticle.positionY);
|
||||||
|
context.lineTo(targetParticle.positionX, targetParticle.positionY);
|
||||||
|
context.strokeStyle = `rgba(139, 92, 246, ${opacity})`;
|
||||||
|
context.lineWidth = 0.5;
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const particle of this.particles) {
|
||||||
|
const deltaX = particle.positionX - this.mousePosition.pointerX;
|
||||||
|
const deltaY = particle.positionY - this.mousePosition.pointerY;
|
||||||
|
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||||
|
|
||||||
|
if (distance < 200) {
|
||||||
|
const opacity = (1 - distance / 200) * 0.25;
|
||||||
|
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(particle.positionX, particle.positionY);
|
||||||
|
context.lineTo(this.mousePosition.pointerX, this.mousePosition.pointerY);
|
||||||
|
context.strokeStyle = `rgba(139, 92, 246, ${opacity})`;
|
||||||
|
context.lineWidth = 0.7;
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.animationId = requestAnimationFrame(() => this.animate());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Particle {
|
||||||
|
positionX: number;
|
||||||
|
positionY: number;
|
||||||
|
velocityX: number;
|
||||||
|
velocityY: number;
|
||||||
|
radius: number;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
46
website/src/app/directives/parallax.directive.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
Directive,
|
||||||
|
ElementRef,
|
||||||
|
inject,
|
||||||
|
Input,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
PLATFORM_ID
|
||||||
|
} from '@angular/core';
|
||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[appParallax]',
|
||||||
|
standalone: true
|
||||||
|
})
|
||||||
|
export class ParallaxDirective implements OnInit, OnDestroy {
|
||||||
|
@Input() appParallax = 0.3;
|
||||||
|
|
||||||
|
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
if (!isPlatformBrowser(this.platformId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('scroll', this.scrollHandler, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (!isPlatformBrowser(this.platformId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.removeEventListener('scroll', this.scrollHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly scrollHandler = () => this.onScroll();
|
||||||
|
|
||||||
|
private onScroll(): void {
|
||||||
|
const scrolled = window.scrollY;
|
||||||
|
const rate = scrolled * this.appParallax;
|
||||||
|
|
||||||
|
this.elementRef.nativeElement.style.transform = `translateY(${rate}px)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
269
website/src/app/pages/downloads/downloads.component.html
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
<div class="min-h-screen pt-32 pb-20">
|
||||||
|
<section class="container mx-auto px-6 mb-16">
|
||||||
|
<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>
|
||||||
|
<p class="text-lg text-muted-foreground leading-relaxed">
|
||||||
|
Available for Windows, Linux, and in your browser. Always free, always the full experience.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Recommended Download -->
|
||||||
|
@if (latestRelease()) {
|
||||||
|
<section class="container mx-auto px-6 mb-16">
|
||||||
|
<div class="max-w-2xl mx-auto section-fade">
|
||||||
|
<div
|
||||||
|
class="rounded-2xl border border-purple-500/20 bg-gradient-to-br from-purple-950/20 to-violet-950/20 backdrop-blur-sm p-8 md:p-10 text-center"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Recommended for you
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold text-foreground mb-2">Toju for {{ detectedOS().name }}</h2>
|
||||||
|
<p class="text-muted-foreground mb-6">Version {{ latestRelease()!.tag_name }}</p>
|
||||||
|
|
||||||
|
@if (recommendedUrl()) {
|
||||||
|
<a
|
||||||
|
[href]="recommendedUrl()"
|
||||||
|
class="inline-flex items-center gap-3 px-8 py-4 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold text-lg hover:from-purple-500 hover:to-violet-500 transition-all shadow-2xl shadow-purple-500/25 hover:shadow-purple-500/40"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Download for {{ detectedOS().name }}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
<p class="text-xs text-muted-foreground/60 mt-4">
|
||||||
|
Or
|
||||||
|
<a
|
||||||
|
href="https://web.toju.app/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="underline hover:text-muted-foreground transition-colors"
|
||||||
|
>
|
||||||
|
use the web version
|
||||||
|
</a>
|
||||||
|
- no download required.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<app-ad-slot />
|
||||||
|
|
||||||
|
<!-- All Downloads for Latest Release -->
|
||||||
|
@if (latestRelease(); as release) {
|
||||||
|
<section class="container mx-auto px-6 mb-20">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<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>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="grid gap-3 section-fade">
|
||||||
|
@for (asset of release.assets; track asset.name) {
|
||||||
|
@if (!isMetaFile(asset.name)) {
|
||||||
|
<a
|
||||||
|
[href]="asset.browser_download_url"
|
||||||
|
class="group flex items-center justify-between gap-4 rounded-xl border border-border/30 bg-card/30 backdrop-blur-sm p-5 hover:border-purple-500/30 hover:bg-card/50 transition-all"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-secondary flex items-center justify-center text-lg">
|
||||||
|
@if (getOsIcon(asset.name)) {
|
||||||
|
<img
|
||||||
|
[src]="getOsIcon(asset.name)"
|
||||||
|
[alt]="releaseService.getAssetOS(asset.name) + ' icon'"
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
class="w-8 h-8 object-contain invert"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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">
|
||||||
|
{{ releaseService.getAssetOS(asset.name) }} · {{ releaseService.formatBytes(asset.size) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-muted-foreground group-hover:text-purple-400 transition-colors"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Historical Releases -->
|
||||||
|
@if (releases().length > 1) {
|
||||||
|
<section class="container mx-auto px-6 mb-20">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<h2 class="text-2xl font-bold text-foreground mb-8 section-fade">Previous Releases</h2>
|
||||||
|
|
||||||
|
<div class="space-y-4 section-fade">
|
||||||
|
@for (release of releases().slice(1); track release.tag_name) {
|
||||||
|
<details class="group rounded-xl border border-border/30 bg-card/30 backdrop-blur-sm overflow-hidden">
|
||||||
|
<summary class="flex items-center justify-between gap-4 p-5 cursor-pointer hover:bg-card/50 transition-colors list-none">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-secondary flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 text-muted-foreground"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 text-muted-foreground group-open:rotate-180 transition-transform"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 9l-7 7-7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</summary>
|
||||||
|
<div class="px-5 pb-5 space-y-2">
|
||||||
|
@if (release.body) {
|
||||||
|
<div class="text-sm text-muted-foreground mb-4 whitespace-pre-line border-b border-border/20 pb-4">{{ release.body }}</div>
|
||||||
|
}
|
||||||
|
@for (asset of release.assets; track asset.name) {
|
||||||
|
@if (!isMetaFile(asset.name) && !asset.name.toLowerCase().includes('server')) {
|
||||||
|
<a
|
||||||
|
[href]="asset.browser_download_url"
|
||||||
|
class="group/item flex items-center justify-between gap-4 rounded-lg border border-border/20 bg-background/50 p-3 hover:border-purple-500/30 transition-all"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
@if (getOsIcon(asset.name)) {
|
||||||
|
<img
|
||||||
|
[src]="getOsIcon(asset.name)"
|
||||||
|
[alt]="releaseService.getAssetOS(asset.name) + ' icon'"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
class="w-4 h-4 object-contain mr-1 invert"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-foreground group-hover/item:text-purple-400 transition-colors">{{ asset.name }}</p>
|
||||||
|
<p class="text-xs text-muted-foreground">{{ releaseService.formatBytes(asset.size) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 text-muted-foreground"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="container mx-auto px-6 text-center py-20">
|
||||||
|
<div class="inline-flex items-center gap-3 text-muted-foreground">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 animate-spin"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
Fetching releases...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- RSS Feed link -->
|
||||||
|
<section class="container mx-auto px-6">
|
||||||
|
<div class="max-w-4xl mx-auto text-center section-fade">
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 inline-block mr-1 -mt-0.5"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
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>
|
||||||
|
Stay updated with our
|
||||||
|
<a
|
||||||
|
href="https://git.azaaxin.com/myxelium/Toju/releases.rss"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="underline hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
RSS feed
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
103
website/src/app/pages/downloads/downloads.component.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
OnInit,
|
||||||
|
AfterViewInit,
|
||||||
|
OnDestroy,
|
||||||
|
inject,
|
||||||
|
PLATFORM_ID,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
import {
|
||||||
|
ReleaseService,
|
||||||
|
Release,
|
||||||
|
DetectedOS
|
||||||
|
} from '../../services/release.service';
|
||||||
|
import { SeoService } from '../../services/seo.service';
|
||||||
|
import { ScrollAnimationService } from '../../services/scroll-animation.service';
|
||||||
|
import { AdSlotComponent } from '../../components/ad-slot/ad-slot.component';
|
||||||
|
import { getOsIconPath } from './os-icon.util';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-downloads',
|
||||||
|
standalone: true,
|
||||||
|
imports: [AdSlotComponent],
|
||||||
|
templateUrl: './downloads.component.html'
|
||||||
|
})
|
||||||
|
export class DownloadsComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
readonly releaseService = inject(ReleaseService);
|
||||||
|
readonly releases = signal<Release[]>([]);
|
||||||
|
readonly latestRelease = signal<Release | null>(null);
|
||||||
|
readonly detectedOS = signal<DetectedOS>({
|
||||||
|
name: 'Linux',
|
||||||
|
icon: '🐧',
|
||||||
|
filePattern: /\.AppImage$/i,
|
||||||
|
ymlFile: 'latest-linux.yml'
|
||||||
|
});
|
||||||
|
readonly recommendedUrl = signal<string | null>(null);
|
||||||
|
readonly loading = signal(true);
|
||||||
|
|
||||||
|
private readonly seoService = inject(SeoService);
|
||||||
|
private readonly scrollAnimation = inject(ScrollAnimationService);
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.seoService.update({
|
||||||
|
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'
|
||||||
|
});
|
||||||
|
|
||||||
|
const os = this.releaseService.detectOS();
|
||||||
|
|
||||||
|
this.detectedOS.set(os);
|
||||||
|
|
||||||
|
this.releaseService.fetchReleases().then((releases) => {
|
||||||
|
this.releases.set(releases);
|
||||||
|
|
||||||
|
if (releases.length > 0) {
|
||||||
|
const latestRelease = releases[0];
|
||||||
|
const recommendedAsset = latestRelease.assets.find(
|
||||||
|
(releaseAsset) => os.filePattern.test(releaseAsset.name) && !releaseAsset.name.toLowerCase().includes('server')
|
||||||
|
);
|
||||||
|
|
||||||
|
this.latestRelease.set(latestRelease);
|
||||||
|
this.recommendedUrl.set(recommendedAsset?.browser_download_url ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading.set(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
setTimeout(() => this.scrollAnimation.init(), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.scrollAnimation.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
isMetaFile(name: string): boolean {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
|
||||||
|
return lower.endsWith('.yml') || lower.endsWith('.yaml') || lower.endsWith('.blockmap') || lower.endsWith('.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
getOsIcon(name: string, size = 64): string {
|
||||||
|
return getOsIconPath(name, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(dateStr: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
website/src/app/pages/downloads/os-icon.util.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
const WINDOWS_SUFFIXES = ['.exe', '.msi'];
|
||||||
|
const WINDOWS_HINTS = ['win', 'windows'];
|
||||||
|
const MAC_SUFFIXES = ['.dmg', '.pkg'];
|
||||||
|
const MAC_HINTS = [
|
||||||
|
'mac',
|
||||||
|
'macos',
|
||||||
|
'osx',
|
||||||
|
'darwin'
|
||||||
|
];
|
||||||
|
const LINUX_SUFFIXES = [
|
||||||
|
'.appimage',
|
||||||
|
'.deb',
|
||||||
|
'.rpm'
|
||||||
|
];
|
||||||
|
const LINUX_HINTS = [
|
||||||
|
'linux',
|
||||||
|
'appimage',
|
||||||
|
'.deb',
|
||||||
|
'.rpm'
|
||||||
|
];
|
||||||
|
const ARCHIVE_SUFFIXES = [
|
||||||
|
'.zip',
|
||||||
|
'.tar',
|
||||||
|
'.tar.gz',
|
||||||
|
'.tgz',
|
||||||
|
'.tar.xz',
|
||||||
|
'.7z',
|
||||||
|
'.rar'
|
||||||
|
];
|
||||||
|
const ARCHIVE_HINTS = [
|
||||||
|
'archive',
|
||||||
|
'.zip',
|
||||||
|
'.tar',
|
||||||
|
'.7z',
|
||||||
|
'.rar'
|
||||||
|
];
|
||||||
|
|
||||||
|
function getSizedIconPath(folder: string, size: number): string {
|
||||||
|
return `/images/${folder}/${size}x${size}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function includesAny(value: string, hints: string[]): boolean {
|
||||||
|
const tokens = value.split(/[^a-z0-9]+/).filter(Boolean);
|
||||||
|
|
||||||
|
return hints.some((hint) => tokens.includes(hint));
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesIconPattern(value: string, suffixes: string[], hints: string[] = []): boolean {
|
||||||
|
return suffixes.some((suffix) => value.endsWith(suffix)) || includesAny(value, hints);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOsIconPath(nameOrOs: string, size = 64): string {
|
||||||
|
const normalized = nameOrOs.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (matchesIconPattern(normalized, WINDOWS_SUFFIXES, WINDOWS_HINTS))
|
||||||
|
return getSizedIconPath('windows', size);
|
||||||
|
|
||||||
|
if (matchesIconPattern(normalized, MAC_SUFFIXES, MAC_HINTS))
|
||||||
|
return getSizedIconPath('macos', size);
|
||||||
|
|
||||||
|
if (matchesIconPattern(normalized, LINUX_SUFFIXES, LINUX_HINTS))
|
||||||
|
return getSizedIconPath('linux', size);
|
||||||
|
|
||||||
|
if (matchesIconPattern(normalized, ARCHIVE_SUFFIXES, ARCHIVE_HINTS))
|
||||||
|
return '/images/misc/zip.png';
|
||||||
|
|
||||||
|
return '/images/misc/file.png';
|
||||||
|
}
|
||||||
95
website/src/app/pages/gallery/gallery.component.html
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<div class="min-h-screen pt-32 pb-20">
|
||||||
|
<section class="container mx-auto px-6 mb-16">
|
||||||
|
<div class="max-w-3xl mx-auto text-center section-fade">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Image Gallery
|
||||||
|
</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>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="container mx-auto px-6 mb-20">
|
||||||
|
<div class="max-w-6xl mx-auto section-fade">
|
||||||
|
<div class="relative overflow-hidden rounded-3xl border border-border/30 bg-card/30 backdrop-blur-sm">
|
||||||
|
<div class="relative aspect-[16/9]">
|
||||||
|
<img
|
||||||
|
ngSrc="/images/screenshots/screenshot_main.png"
|
||||||
|
alt="Toju main application screenshot"
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
sizes="(min-width: 1536px) 75vw, (min-width: 1280px) 90vw, 100vw"
|
||||||
|
class="object-cover"
|
||||||
|
/>
|
||||||
|
</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">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-purple-400 mb-2">Featured</p>
|
||||||
|
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-2">The full Toju workspace</h2>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<app-ad-slot />
|
||||||
|
|
||||||
|
<section class="container mx-auto px-6 mb-20">
|
||||||
|
<div class="max-w-6xl mx-auto">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||||
|
@for (item of galleryItems; track item.src) {
|
||||||
|
<a
|
||||||
|
[href]="item.src"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="section-fade group overflow-hidden rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm hover:border-purple-500/30 hover:bg-card/50 transition-all"
|
||||||
|
>
|
||||||
|
<div class="relative aspect-video overflow-hidden">
|
||||||
|
<img
|
||||||
|
[ngSrc]="item.src"
|
||||||
|
[alt]="item.title"
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 768px) 100vw, (max-width: 1280px) 50vw, 33vw"
|
||||||
|
class="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="p-5">
|
||||||
|
<h3 class="text-lg font-semibold text-foreground mb-2">{{ item.title }}</h3>
|
||||||
|
<p class="text-sm text-muted-foreground leading-relaxed">{{ item.description }}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="container mx-auto px-6">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-4">Want to see it in action?</h2>
|
||||||
|
<p class="text-muted-foreground leading-relaxed mb-6">Download Toju or jump into the browser experience and explore the interface yourself.</p>
|
||||||
|
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
|
<a
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Go to downloads
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://web.toju.app/"
|
||||||
|
target="_blank"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Open web version
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
91
website/src/app/pages/gallery/gallery.component.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
AfterViewInit,
|
||||||
|
Component,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
PLATFORM_ID,
|
||||||
|
inject
|
||||||
|
} from '@angular/core';
|
||||||
|
import { isPlatformBrowser, NgOptimizedImage } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { AdSlotComponent } from '../../components/ad-slot/ad-slot.component';
|
||||||
|
import { ScrollAnimationService } from '../../services/scroll-animation.service';
|
||||||
|
import { SeoService } from '../../services/seo.service';
|
||||||
|
|
||||||
|
interface GalleryItem {
|
||||||
|
src: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-gallery',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
NgOptimizedImage,
|
||||||
|
RouterLink,
|
||||||
|
AdSlotComponent
|
||||||
|
],
|
||||||
|
templateUrl: './gallery.component.html'
|
||||||
|
})
|
||||||
|
export class GalleryComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
readonly galleryItems: GalleryItem[] = [
|
||||||
|
{
|
||||||
|
src: '/images/screenshots/screenshot_main.png',
|
||||||
|
title: 'Main chat view',
|
||||||
|
description: 'The core Toju experience with channels, messages, and direct communication tools.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/images/screenshots/screenshare_gaming.png',
|
||||||
|
title: 'Gaming screen share',
|
||||||
|
description: 'Share gameplay, guides, and live moments with smooth full-resolution screen sharing.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/images/screenshots/serverViewScreen.png',
|
||||||
|
title: 'Server overview',
|
||||||
|
description: 'Navigate servers and rooms with a layout designed for clarity and speed.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/images/screenshots/music.png',
|
||||||
|
title: 'Music and voice',
|
||||||
|
description: 'Stay in sync with voice and media features in a focused, low-friction interface.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/images/screenshots/videos.png',
|
||||||
|
title: 'Video sharing',
|
||||||
|
description: 'Preview and share visual content directly with your friends and communities.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/images/screenshots/filedownload.png',
|
||||||
|
title: 'File transfers',
|
||||||
|
description: 'Move files quickly without artificial size limits or unnecessary hoops.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/images/screenshots/gif.png',
|
||||||
|
title: 'Rich media chat',
|
||||||
|
description: 'Conversations stay lively with visual media support built right in.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
private readonly seoService = inject(SeoService);
|
||||||
|
private readonly scrollAnimation = inject(ScrollAnimationService);
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.seoService.update({
|
||||||
|
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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
setTimeout(() => this.scrollAnimation.init(), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.scrollAnimation.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
542
website/src/app/pages/home/home.component.html
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
<!-- Hero -->
|
||||||
|
<section class="relative min-h-screen flex items-center justify-center overflow-hidden">
|
||||||
|
<!-- Gradient orbs -->
|
||||||
|
<div
|
||||||
|
[appParallax]="0.15"
|
||||||
|
class="absolute top-1/4 -left-32 w-96 h-96 bg-purple-600/20 rounded-full blur-[128px] animate-float"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
[appParallax]="0.25"
|
||||||
|
class="absolute bottom-1/4 -right-32 w-80 h-80 bg-violet-500/15 rounded-full blur-[100px] animate-float"
|
||||||
|
style="animation-delay: -3s"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div class="relative z-10 container mx-auto px-6 text-center pt-32 pb-20">
|
||||||
|
<div
|
||||||
|
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>
|
||||||
|
Currently in Beta - Free & Open Source
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="gradient-text">Own your voice.</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Crystal-clear voice calls, unlimited screen sharing, and file transfers with no size limits. Peer-to-peer. Private. Completely free.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- CTA Buttons -->
|
||||||
|
<div
|
||||||
|
class="flex flex-col sm:flex-row items-center justify-center gap-4 mb-6 animate-fade-in-up"
|
||||||
|
style="animation-delay: 0.4s"
|
||||||
|
>
|
||||||
|
@if (downloadUrl()) {
|
||||||
|
<a
|
||||||
|
[href]="downloadUrl()"
|
||||||
|
class="group inline-flex items-center gap-3 px-8 py-4 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold text-lg hover:from-purple-500 hover:to-violet-500 transition-all shadow-2xl shadow-purple-500/25 hover:shadow-purple-500/40 animate-glow-pulse"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Download for {{ detectedOS().name }}
|
||||||
|
<span class="text-sm opacity-75">{{ detectedOS().icon }}</span>
|
||||||
|
</a>
|
||||||
|
} @else {
|
||||||
|
<a
|
||||||
|
routerLink="/downloads"
|
||||||
|
class="group inline-flex items-center gap-3 px-8 py-4 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold text-lg hover:from-purple-500 hover:to-violet-500 transition-all shadow-2xl shadow-purple-500/25 hover:shadow-purple-500/40"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Download Toju
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://web.toju.app/"
|
||||||
|
target="_blank"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Open in Browser
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 opacity-60"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (latestVersion()) {
|
||||||
|
<p
|
||||||
|
class="text-xs text-muted-foreground/60 animate-fade-in"
|
||||||
|
style="animation-delay: 0.6s"
|
||||||
|
>
|
||||||
|
Version {{ latestVersion() }} ·
|
||||||
|
<a
|
||||||
|
routerLink="/downloads"
|
||||||
|
class="underline hover:text-muted-foreground transition-colors"
|
||||||
|
>All platforms</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Scroll indicator -->
|
||||||
|
<div class="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce">
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6 text-muted-foreground/40"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<app-ad-slot />
|
||||||
|
|
||||||
|
<!-- Features Grid -->
|
||||||
|
<section class="relative py-32">
|
||||||
|
<div class="container mx-auto px-6">
|
||||||
|
<div class="text-center mb-20 section-fade">
|
||||||
|
<h2 class="text-3xl md:text-5xl font-bold text-foreground mb-4">
|
||||||
|
Everything you need,<br />
|
||||||
|
<span class="gradient-text">nothing you don't.</span>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<!-- Voice Calls -->
|
||||||
|
<div
|
||||||
|
class="section-fade group relative rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8 hover:border-purple-500/30 hover:bg-card/50 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-purple-500/10 flex items-center justify-center mb-6 group-hover:bg-purple-500/20 transition-colors">
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6 text-purple-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-foreground mb-3">HD Voice Calls</h3>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Screen Sharing -->
|
||||||
|
<div
|
||||||
|
class="section-fade group relative rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8 hover:border-purple-500/30 hover:bg-card/50 transition-all duration-300"
|
||||||
|
style="transition-delay: 0.1s"
|
||||||
|
>
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-violet-500/10 flex items-center justify-center mb-6 group-hover:bg-violet-500/20 transition-colors">
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6 text-violet-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-foreground mb-3">Screen Sharing</h3>
|
||||||
|
<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
|
||||||
|
epic gameplay.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File Sharing -->
|
||||||
|
<div
|
||||||
|
class="section-fade group relative rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8 hover:border-purple-500/30 hover:bg-card/50 transition-all duration-300"
|
||||||
|
style="transition-delay: 0.2s"
|
||||||
|
>
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-pink-500/10 flex items-center justify-center mb-6 group-hover:bg-pink-500/20 transition-colors">
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6 text-pink-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-foreground mb-3">Unlimited File Sharing</h3>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Privacy -->
|
||||||
|
<div
|
||||||
|
class="section-fade group relative rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8 hover:border-purple-500/30 hover:bg-card/50 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-emerald-500/10 flex items-center justify-center mb-6 group-hover:bg-emerald-500/20 transition-colors">
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6 text-emerald-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-foreground mb-3">True Privacy</h3>
|
||||||
|
<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
|
||||||
|
business.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Open Source -->
|
||||||
|
<div
|
||||||
|
class="section-fade group relative rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8 hover:border-purple-500/30 hover:bg-card/50 transition-all duration-300"
|
||||||
|
style="transition-delay: 0.1s"
|
||||||
|
>
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-blue-500/10 flex items-center justify-center mb-6 group-hover:bg-blue-500/20 transition-colors">
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6 text-blue-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-foreground mb-3">Open Source</h3>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Free -->
|
||||||
|
<div
|
||||||
|
class="section-fade group relative rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8 hover:border-purple-500/30 hover:bg-card/50 transition-all duration-300"
|
||||||
|
style="transition-delay: 0.2s"
|
||||||
|
>
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-amber-500/10 flex items-center justify-center mb-6 group-hover:bg-amber-500/20 transition-colors">
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6 text-amber-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-foreground mb-3">Completely Free</h3>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<app-ad-slot />
|
||||||
|
|
||||||
|
<!-- Gaming Section -->
|
||||||
|
<section class="relative py-32">
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-b from-transparent via-purple-950/10 to-transparent"></div>
|
||||||
|
<div class="relative container mx-auto px-6">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
|
||||||
|
<div class="section-fade">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Built for Gamers
|
||||||
|
</div>
|
||||||
|
<h2 class="text-3xl md:text-5xl font-bold text-foreground mb-6">
|
||||||
|
Your perfect<br />
|
||||||
|
<span class="gradient-text">gaming companion.</span>
|
||||||
|
</h2>
|
||||||
|
<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
|
||||||
|
while keeping your CPU free for what matters - winning.
|
||||||
|
</p>
|
||||||
|
<ul class="space-y-4">
|
||||||
|
<li class="flex items-center gap-3 text-muted-foreground">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-purple-400 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Low-latency peer-to-peer voice - no relay servers in the way</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-3 text-muted-foreground">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-purple-400 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>AI-powered noise suppression - keyboard clatter stays out</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-3 text-muted-foreground">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-purple-400 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Full-resolution screen sharing at high FPS</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-3 text-muted-foreground">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-purple-400 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Send replays and screenshots with no file size limit</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-fade relative">
|
||||||
|
<div class="relative rounded-2xl overflow-hidden border border-border/30 bg-card/30 backdrop-blur-sm aspect-video">
|
||||||
|
<img
|
||||||
|
ngSrc="/images/screenshots/screenshare_gaming.png"
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
alt="Toju gaming screen sharing preview"
|
||||||
|
class="object-cover"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
<!-- Glow effect -->
|
||||||
|
<div class="absolute -inset-4 bg-purple-600/5 rounded-3xl blur-2xl -z-10"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<app-ad-slot />
|
||||||
|
|
||||||
|
<!-- Self-hostable Section -->
|
||||||
|
<section class="relative py-32">
|
||||||
|
<div class="container mx-auto px-6">
|
||||||
|
<div class="section-fade max-w-3xl mx-auto text-center">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-sm font-medium mb-6"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="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>
|
||||||
|
Self-Hostable
|
||||||
|
</div>
|
||||||
|
<h2 class="text-3xl md:text-5xl font-bold text-foreground mb-6">
|
||||||
|
Your infrastructure,<br />
|
||||||
|
<span class="gradient-text">your rules.</span>
|
||||||
|
</h2>
|
||||||
|
<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
|
||||||
|
even more control? Run your own coordination server in minutes. Full independence, zero compromises.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
|
<a
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Learn how it works
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://git.azaaxin.com/myxelium/Toju"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="inline-flex items-center gap-2 px-6 py-3 text-muted-foreground hover:text-foreground transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/images/gitea.png"
|
||||||
|
alt=""
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
class="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
View source code
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CTA Banner -->
|
||||||
|
<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="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>
|
||||||
|
<p class="text-muted-foreground text-lg mb-8 max-w-lg mx-auto">Join thousands choosing privacy, freedom, and real connection.</p>
|
||||||
|
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
|
@if (downloadUrl()) {
|
||||||
|
<a
|
||||||
|
[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"
|
||||||
|
>
|
||||||
|
Download for {{ detectedOS().name }}
|
||||||
|
</a>
|
||||||
|
} @else {
|
||||||
|
<a
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Download Toju
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
<a
|
||||||
|
href="https://web.toju.app/"
|
||||||
|
target="_blank"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Try in Browser
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
77
website/src/app/pages/home/home.component.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
OnInit,
|
||||||
|
OnDestroy,
|
||||||
|
AfterViewInit,
|
||||||
|
inject,
|
||||||
|
PLATFORM_ID,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import { isPlatformBrowser, NgOptimizedImage } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { ReleaseService, DetectedOS } from '../../services/release.service';
|
||||||
|
import { SeoService } from '../../services/seo.service';
|
||||||
|
import { ScrollAnimationService } from '../../services/scroll-animation.service';
|
||||||
|
import { AdSlotComponent } from '../../components/ad-slot/ad-slot.component';
|
||||||
|
import { ParallaxDirective } from '../../directives/parallax.directive';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-home',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
NgOptimizedImage,
|
||||||
|
RouterLink,
|
||||||
|
AdSlotComponent,
|
||||||
|
ParallaxDirective
|
||||||
|
],
|
||||||
|
templateUrl: './home.component.html'
|
||||||
|
})
|
||||||
|
export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
readonly detectedOS = signal<DetectedOS>({
|
||||||
|
name: 'Linux',
|
||||||
|
icon: '🐧',
|
||||||
|
filePattern: /\.AppImage$/i,
|
||||||
|
ymlFile: 'latest-linux.yml'
|
||||||
|
});
|
||||||
|
readonly downloadUrl = signal<string | null>(null);
|
||||||
|
readonly latestVersion = signal<string | null>(null);
|
||||||
|
|
||||||
|
private readonly releaseService = inject(ReleaseService);
|
||||||
|
private readonly seoService = inject(SeoService);
|
||||||
|
private readonly scrollAnimation = inject(ScrollAnimationService);
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.seoService.update({
|
||||||
|
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/'
|
||||||
|
});
|
||||||
|
|
||||||
|
const os = this.releaseService.detectOS();
|
||||||
|
|
||||||
|
this.detectedOS.set(os);
|
||||||
|
|
||||||
|
this.releaseService.getLatestRelease().then((release) => {
|
||||||
|
if (release) {
|
||||||
|
this.latestVersion.set(release.tag_name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.releaseService.getDownloadUrl(os).then((url) => {
|
||||||
|
this.downloadUrl.set(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
setTimeout(() => this.scrollAnimation.init(), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.scrollAnimation.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
214
website/src/app/pages/philosophy/philosophy.component.html
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
<div class="min-h-screen pt-32 pb-20">
|
||||||
|
<!-- Hero -->
|
||||||
|
<section class="container mx-auto px-6 mb-24">
|
||||||
|
<div class="max-w-3xl mx-auto text-center">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Our Manifesto
|
||||||
|
</div>
|
||||||
|
<h1 class="text-4xl md:text-6xl font-extrabold text-foreground mb-6">Why we <span class="gradient-text">build</span> Toju</h1>
|
||||||
|
<p class="text-lg text-muted-foreground leading-relaxed">A letter from the people behind the project.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<app-ad-slot />
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<section class="container mx-auto px-6 mb-24">
|
||||||
|
<article class="max-w-3xl mx-auto prose prose-invert prose-lg">
|
||||||
|
<!-- Ownership -->
|
||||||
|
<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>
|
||||||
|
<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.
|
||||||
|
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 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 -
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No predatory pricing -->
|
||||||
|
<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>
|
||||||
|
<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
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-ad-slot />
|
||||||
|
|
||||||
|
<!-- Privacy as a right -->
|
||||||
|
<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>
|
||||||
|
<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
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Better world -->
|
||||||
|
<div class="section-fade mb-16">
|
||||||
|
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">Built from the Heart</h2>
|
||||||
|
<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
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Open source -->
|
||||||
|
<div class="section-fade mb-16">
|
||||||
|
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">Transparent by Default</h2>
|
||||||
|
<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
|
||||||
|
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 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
|
||||||
|
communication infrastructure shouldn't depend on a single organization's survival.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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">
|
||||||
|
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6 !mt-0">Our Promise</h2>
|
||||||
|
<ul class="space-y-4 text-muted-foreground !list-none !pl-0">
|
||||||
|
<li class="flex items-start gap-3">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-purple-400 flex-shrink-0 mt-0.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>We will <strong class="text-foreground">never</strong> lock features behind a paywall.</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start gap-3">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-purple-400 flex-shrink-0 mt-0.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>We will <strong class="text-foreground">never</strong> sell, monetize, or harvest your data.</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start gap-3">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-purple-400 flex-shrink-0 mt-0.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>We will <strong class="text-foreground">always</strong> keep the source code open and auditable.</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start gap-3">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-purple-400 flex-shrink-0 mt-0.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>We will <strong class="text-foreground">always</strong> put users before profit.</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="text-muted-foreground mt-6 text-sm">- The Myxelium team</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Support CTA -->
|
||||||
|
<section class="container mx-auto px-6">
|
||||||
|
<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>
|
||||||
|
<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
|
||||||
|
forward - without ever compromising our values.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
|
<a
|
||||||
|
href="https://buymeacoffee.com/myxelium"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-gradient-to-r from-yellow-500 to-amber-500 text-black font-semibold hover:from-yellow-400 hover:to-amber-400 transition-all shadow-lg shadow-yellow-500/25"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/images/buymeacoffee.png"
|
||||||
|
alt=""
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
class="w-5 h-5 object-contain"
|
||||||
|
/>
|
||||||
|
Buy us a coffee
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Download Toju
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
45
website/src/app/pages/philosophy/philosophy.component.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
OnInit,
|
||||||
|
AfterViewInit,
|
||||||
|
OnDestroy,
|
||||||
|
inject,
|
||||||
|
PLATFORM_ID
|
||||||
|
} from '@angular/core';
|
||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { SeoService } from '../../services/seo.service';
|
||||||
|
import { ScrollAnimationService } from '../../services/scroll-animation.service';
|
||||||
|
import { AdSlotComponent } from '../../components/ad-slot/ad-slot.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-philosophy',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterLink, AdSlotComponent],
|
||||||
|
templateUrl: './philosophy.component.html'
|
||||||
|
})
|
||||||
|
export class PhilosophyComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
private readonly seoService = inject(SeoService);
|
||||||
|
private readonly scrollAnimation = inject(ScrollAnimationService);
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.seoService.update({
|
||||||
|
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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
setTimeout(() => this.scrollAnimation.init(), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.scrollAnimation.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
261
website/src/app/pages/what-is-toju/what-is-toju.component.html
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
<div class="min-h-screen pt-32 pb-20">
|
||||||
|
<!-- Hero -->
|
||||||
|
<section class="container mx-auto px-6 mb-24">
|
||||||
|
<div class="max-w-3xl mx-auto text-center">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
The Big Picture
|
||||||
|
</div>
|
||||||
|
<h1 class="text-4xl md:text-6xl font-extrabold text-foreground mb-6">What is <span class="gradient-text">Toju</span>?</h1>
|
||||||
|
<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
|
||||||
|
through someone else's servers. Think of it as your own private phone line that nobody can tap into.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<app-ad-slot />
|
||||||
|
|
||||||
|
<!-- How it works -->
|
||||||
|
<section class="container mx-auto px-6 mb-24">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<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>?
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="grid gap-8">
|
||||||
|
<!-- Step 1 -->
|
||||||
|
<div class="section-fade relative rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8 md:p-10">
|
||||||
|
<div class="flex items-start gap-6">
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 w-12 h-12 rounded-full bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-400 font-bold text-lg"
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold text-foreground mb-3">You connect directly to your friends</h3>
|
||||||
|
<p class="text-muted-foreground leading-relaxed">
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Step 2 -->
|
||||||
|
<div class="section-fade relative rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8 md:p-10">
|
||||||
|
<div class="flex items-start gap-6">
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 w-12 h-12 rounded-full bg-violet-500/10 border border-violet-500/20 flex items-center justify-center text-violet-400 font-bold text-lg"
|
||||||
|
>
|
||||||
|
2
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold text-foreground mb-3">A tiny helper gets you connected</h3>
|
||||||
|
<p class="text-muted-foreground leading-relaxed">
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Step 3 -->
|
||||||
|
<div class="section-fade relative rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8 md:p-10">
|
||||||
|
<div class="flex items-start gap-6">
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 w-12 h-12 rounded-full bg-pink-500/10 border border-pink-500/20 flex items-center justify-center text-pink-400 font-bold text-lg"
|
||||||
|
>
|
||||||
|
3
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold text-foreground mb-3">No limits because there are no middlemen</h3>
|
||||||
|
<p class="text-muted-foreground leading-relaxed">
|
||||||
|
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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<app-ad-slot />
|
||||||
|
|
||||||
|
<!-- Why designed this way -->
|
||||||
|
<section class="container mx-auto px-6 mb-24">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<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?
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-8">
|
||||||
|
<div class="section-fade rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-emerald-500/10 flex items-center justify-center mb-4">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-emerald-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-foreground mb-3">Privacy by Architecture</h3>
|
||||||
|
<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
|
||||||
|
nothing to hack, subpoena, or sell. Your privacy isn't protected by a promise; it's protected by how the technology works.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-fade rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center mb-4">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-blue-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-foreground mb-3">Performance Without Compromise</h3>
|
||||||
|
<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
|
||||||
|
it actually takes to transfer - not in the time it takes to upload, store, then download.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-fade rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-amber-500/10 flex items-center justify-center mb-4">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-amber-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-foreground mb-3">Sustainable & Free</h3>
|
||||||
|
<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
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-fade rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-purple-500/10 flex items-center justify-center mb-4">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-purple-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-foreground mb-3">Independence & Freedom</h3>
|
||||||
|
<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
|
||||||
|
still use Toju. Your communication tools should belong to you, not a corporation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FAQ-style section -->
|
||||||
|
<section class="container mx-auto px-6 mb-24">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<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>
|
||||||
|
<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
|
||||||
|
costs are minimal, and we fund development through community support and donations. Every feature is available to everyone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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
|
||||||
|
behind the scenes. You just enjoy the benefits of better privacy, performance, and no limits.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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,
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<section class="container mx-auto px-6">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-4">Ready to try it?</h2>
|
||||||
|
<p class="text-muted-foreground mb-8">Available on Windows, Linux, and in your browser. Always free.</p>
|
||||||
|
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
|
<a
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Download Toju
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://web.toju.app/"
|
||||||
|
target="_blank"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Open in Browser
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
45
website/src/app/pages/what-is-toju/what-is-toju.component.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
OnInit,
|
||||||
|
AfterViewInit,
|
||||||
|
OnDestroy,
|
||||||
|
inject,
|
||||||
|
PLATFORM_ID
|
||||||
|
} from '@angular/core';
|
||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { SeoService } from '../../services/seo.service';
|
||||||
|
import { ScrollAnimationService } from '../../services/scroll-animation.service';
|
||||||
|
import { AdSlotComponent } from '../../components/ad-slot/ad-slot.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-what-is-toju',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterLink, AdSlotComponent],
|
||||||
|
templateUrl: './what-is-toju.component.html'
|
||||||
|
})
|
||||||
|
export class WhatIsTojuComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
private readonly seoService = inject(SeoService);
|
||||||
|
private readonly scrollAnimation = inject(ScrollAnimationService);
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.seoService.update({
|
||||||
|
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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
setTimeout(() => this.scrollAnimation.init(), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.scrollAnimation.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
14
website/src/app/services/ad.service.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AdService {
|
||||||
|
readonly adsEnabled = signal(false);
|
||||||
|
|
||||||
|
enableAds(): void {
|
||||||
|
this.adsEnabled.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
disableAds(): void {
|
||||||
|
this.adsEnabled.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
251
website/src/app/services/release.service.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
PLATFORM_ID,
|
||||||
|
inject
|
||||||
|
} from '@angular/core';
|
||||||
|
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
|
||||||
|
|
||||||
|
export interface ReleaseAsset {
|
||||||
|
name: string;
|
||||||
|
browser_download_url: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Release {
|
||||||
|
tag_name: string;
|
||||||
|
name: string;
|
||||||
|
published_at: string;
|
||||||
|
body: string;
|
||||||
|
assets: ReleaseAsset[];
|
||||||
|
html_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetectedOS {
|
||||||
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
|
filePattern: RegExp;
|
||||||
|
ymlFile: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WINDOWS_SUFFIXES = ['.exe', '.msi'];
|
||||||
|
const WINDOWS_HINTS = [
|
||||||
|
'setup',
|
||||||
|
'win',
|
||||||
|
'windows'
|
||||||
|
];
|
||||||
|
const MAC_SUFFIXES = ['.dmg', '.pkg'];
|
||||||
|
const MAC_HINTS = [
|
||||||
|
'mac',
|
||||||
|
'macos',
|
||||||
|
'osx',
|
||||||
|
'darwin'
|
||||||
|
];
|
||||||
|
const LINUX_SUFFIXES = [
|
||||||
|
'.appimage',
|
||||||
|
'.deb',
|
||||||
|
'.rpm'
|
||||||
|
];
|
||||||
|
const LINUX_HINTS = ['linux'];
|
||||||
|
const ARCHIVE_SUFFIXES = [
|
||||||
|
'.zip',
|
||||||
|
'.tar',
|
||||||
|
'.tar.gz',
|
||||||
|
'.tgz',
|
||||||
|
'.tar.xz',
|
||||||
|
'.7z',
|
||||||
|
'.rar'
|
||||||
|
];
|
||||||
|
const DIRECT_RELEASES_API_URL = 'https://git.azaaxin.com/api/v1/repos/myxelium/Toju/releases';
|
||||||
|
const PROXY_RELEASES_API_URL = '/api/releases';
|
||||||
|
|
||||||
|
function matchesAssetPattern(name: string, suffixes: string[], hints: string[] = []): boolean {
|
||||||
|
if (suffixes.some((suffix) => name.endsWith(suffix))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = name.split(/[^a-z0-9]+/).filter(Boolean);
|
||||||
|
|
||||||
|
return hints.some((hint) => tokens.includes(hint));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ReleaseService {
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
|
||||||
|
private cachedReleases: Release[] | null = null;
|
||||||
|
private fetchPromise: Promise<Release[]> | null = null;
|
||||||
|
|
||||||
|
detectOS(): DetectedOS {
|
||||||
|
if (!isPlatformBrowser(this.platformId)) {
|
||||||
|
return {
|
||||||
|
name: 'Linux',
|
||||||
|
icon: null,
|
||||||
|
filePattern: /\.AppImage$/i,
|
||||||
|
ymlFile: 'latest-linux.yml'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAgent = navigator.userAgent.toLowerCase();
|
||||||
|
|
||||||
|
if (userAgent.includes('win')) {
|
||||||
|
return {
|
||||||
|
name: 'Windows',
|
||||||
|
icon: null,
|
||||||
|
filePattern: /\.exe$/i,
|
||||||
|
ymlFile: 'latest.yml'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userAgent.includes('mac')) {
|
||||||
|
return {
|
||||||
|
name: 'macOS',
|
||||||
|
icon: null,
|
||||||
|
filePattern: /\.dmg$/i,
|
||||||
|
ymlFile: 'latest-mac.yml'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUbuntuDebian = userAgent.includes('ubuntu') || userAgent.includes('debian');
|
||||||
|
|
||||||
|
if (isUbuntuDebian) {
|
||||||
|
return {
|
||||||
|
name: 'Linux (deb)',
|
||||||
|
icon: null,
|
||||||
|
filePattern: /\.deb$/i,
|
||||||
|
ymlFile: 'latest-linux.yml'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'Linux',
|
||||||
|
icon: null,
|
||||||
|
filePattern: /\.AppImage$/i,
|
||||||
|
ymlFile: 'latest-linux.yml'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchReleases(): Promise<Release[]> {
|
||||||
|
if (this.cachedReleases) {
|
||||||
|
return Promise.resolve(this.cachedReleases);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlatformServer(this.platformId)) {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.fetchPromise) {
|
||||||
|
return this.fetchPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.fetchPromise = this.fetchReleasesInternal();
|
||||||
|
|
||||||
|
return this.fetchPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLatestRelease(): Promise<Release | null> {
|
||||||
|
const releases = await this.fetchReleases();
|
||||||
|
|
||||||
|
return releases.length > 0 ? releases[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDownloadUrl(os: DetectedOS): Promise<string | null> {
|
||||||
|
const release = await this.getLatestRelease();
|
||||||
|
|
||||||
|
if (!release) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingAsset = release.assets.find(
|
||||||
|
(releaseAsset) => os.filePattern.test(releaseAsset.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
return matchingAsset?.browser_download_url ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAssetOS(name: string): string {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
|
||||||
|
if (matchesAssetPattern(lower, WINDOWS_SUFFIXES, WINDOWS_HINTS)) {
|
||||||
|
return 'Windows';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesAssetPattern(lower, MAC_SUFFIXES, MAC_HINTS)) {
|
||||||
|
return 'macOS';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesAssetPattern(lower, LINUX_SUFFIXES, LINUX_HINTS)) {
|
||||||
|
return 'Linux';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesAssetPattern(lower, ARCHIVE_SUFFIXES)) {
|
||||||
|
return 'Archive';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.endsWith('.wasm')) {
|
||||||
|
return 'Web';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Other';
|
||||||
|
}
|
||||||
|
|
||||||
|
formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) {
|
||||||
|
return '0 B';
|
||||||
|
}
|
||||||
|
|
||||||
|
const kilobyte = 1024;
|
||||||
|
const units = [
|
||||||
|
'B',
|
||||||
|
'KB',
|
||||||
|
'MB',
|
||||||
|
'GB'
|
||||||
|
];
|
||||||
|
const unitIndex = Math.floor(Math.log(bytes) / Math.log(kilobyte));
|
||||||
|
|
||||||
|
return `${parseFloat((bytes / Math.pow(kilobyte, unitIndex)).toFixed(1))} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchReleasesInternal(): Promise<Release[]> {
|
||||||
|
try {
|
||||||
|
const data = await this.fetchReleasesFromAvailableEndpoints();
|
||||||
|
|
||||||
|
this.cachedReleases = Array.isArray(data) ? data : [data];
|
||||||
|
|
||||||
|
return this.cachedReleases;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch releases:', error);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
this.fetchPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchReleasesFromAvailableEndpoints(): Promise<Release[] | Release> {
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
for (const endpoint of this.getReleaseEndpoints()) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
headers: { Accept: 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError instanceof Error
|
||||||
|
? lastError
|
||||||
|
: new Error('Failed to fetch releases from all configured endpoints.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private getReleaseEndpoints(): string[] {
|
||||||
|
return [PROXY_RELEASES_API_URL, DIRECT_RELEASES_API_URL];
|
||||||
|
}
|
||||||
|
}
|
||||||
41
website/src/app/services/scroll-animation.service.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
inject,
|
||||||
|
PLATFORM_ID
|
||||||
|
} from '@angular/core';
|
||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ScrollAnimationService {
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
private observer: IntersectionObserver | null = null;
|
||||||
|
|
||||||
|
init(): void {
|
||||||
|
if (!isPlatformBrowser(this.platformId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
entry.target.classList.add('visible');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold: 0.1, rootMargin: '0px 0px -50px 0px' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Observe all elements with section-fade class
|
||||||
|
document.querySelectorAll('.section-fade').forEach((el) => {
|
||||||
|
this.observer?.observe(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
observe(element: HTMLElement): void {
|
||||||
|
this.observer?.observe(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.observer?.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
37
website/src/app/services/seo.service.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Meta, Title } from '@angular/platform-browser';
|
||||||
|
|
||||||
|
interface SeoData {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
url?: string;
|
||||||
|
image?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class SeoService {
|
||||||
|
private readonly meta = inject(Meta);
|
||||||
|
private readonly title = inject(Title);
|
||||||
|
|
||||||
|
update(data: SeoData): void {
|
||||||
|
const fullTitle = `${data.title} - Toju`;
|
||||||
|
|
||||||
|
this.title.setTitle(fullTitle);
|
||||||
|
|
||||||
|
this.meta.updateTag({ name: 'description', content: data.description });
|
||||||
|
this.meta.updateTag({ property: 'og:title', content: fullTitle });
|
||||||
|
this.meta.updateTag({ property: 'og:description', content: data.description });
|
||||||
|
this.meta.updateTag({ name: 'twitter:title', content: fullTitle });
|
||||||
|
this.meta.updateTag({ name: 'twitter:description', content: data.description });
|
||||||
|
|
||||||
|
if (data.url) {
|
||||||
|
this.meta.updateTag({ property: 'og:url', content: data.url });
|
||||||
|
this.meta.updateTag({ rel: 'canonical', href: data.url });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.image) {
|
||||||
|
this.meta.updateTag({ property: 'og:image', content: data.image });
|
||||||
|
this.meta.updateTag({ name: 'twitter:image', content: data.image });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
website/src/images/buymeacoffee.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
website/src/images/gitea.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
website/src/images/github.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
website/src/images/linux/64x64.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
website/src/images/macos/64x64.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
website/src/images/misc/file.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
website/src/images/misc/zip.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
website/src/images/screenshots/filedownload.png
Normal file
|
After Width: | Height: | Size: 356 KiB |
BIN
website/src/images/screenshots/gif.png
Normal file
|
After Width: | Height: | Size: 505 KiB |
BIN
website/src/images/screenshots/music.png
Normal file
|
After Width: | Height: | Size: 366 KiB |
BIN
website/src/images/screenshots/screenshare_gaming.png
Normal file
|
After Width: | Height: | Size: 816 KiB |
BIN
website/src/images/screenshots/screenshot_main.png
Normal file
|
After Width: | Height: | Size: 281 KiB |
BIN
website/src/images/screenshots/serverViewScreen.png
Normal file
|
After Width: | Height: | Size: 367 KiB |
BIN
website/src/images/screenshots/videos.png
Normal file
|
After Width: | Height: | Size: 549 KiB |
BIN
website/src/images/toju-logo-transparent.png
Normal file
|
After Width: | Height: | Size: 37 KiB |