Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8fd78d21a | |||
| 150c45c31a | |||
| 00adf39121 | |||
| 2b6e477c9a | |||
| 22d355a522 | |||
| 15c5952e29 | |||
| 781c05294f | |||
| 778e75bef5 | |||
| 7bf37ba510 | |||
| 3c04b5db26 | |||
| 45e0b09af8 | |||
| 106212ef3d | |||
| be465fd297 | |||
| 7b3caa0b61 |
@@ -1,4 +1,5 @@
|
||||
# Toggle SSL for local development (true/false)
|
||||
# When true: ng serve uses --ssl, Express API uses HTTPS, Electron loads https://
|
||||
# When false: plain HTTP everywhere (only works on localhost)
|
||||
# Overrides server/data/variables.json for local development only
|
||||
SSL=true
|
||||
|
||||
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
|
||||
101
README.md
@@ -17,7 +17,7 @@ Desktop chat app with three parts:
|
||||
Root `.env`:
|
||||
|
||||
- `SSL=true` uses HTTPS for Angular, the server, and Electron dev mode
|
||||
- `PORT=3001` changes the server port
|
||||
- `PORT=3001` changes the server port in local development and overrides the server app setting
|
||||
|
||||
If `SSL=true`, run `./generate-cert.sh` once.
|
||||
|
||||
@@ -25,101 +25,10 @@ Server files:
|
||||
|
||||
- `server/data/variables.json` holds `klipyApiKey`
|
||||
- `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates
|
||||
|
||||
## Desktop auto updates
|
||||
|
||||
The packaged Electron app now reads a hosted release manifest from the active server's `/api/health` response.
|
||||
|
||||
Release flow:
|
||||
|
||||
1. Build the desktop packages with `npm run electron:build` or the platform-specific Electron Builder commands.
|
||||
2. Upload one version folder that contains the generated `latest.yml`, `latest-mac.yml`, `latest-linux.yml`, and the matching installers/artifacts.
|
||||
3. Generate or update the hosted manifest JSON with:
|
||||
|
||||
`npm run release:manifest -- --feed-url https://your-cdn.example.com/metoyou/1.2.3`
|
||||
|
||||
4. Set `releaseManifestUrl` in `server/data/variables.json` to the hosted manifest JSON URL.
|
||||
|
||||
### GitHub / Gitea release assets
|
||||
|
||||
If you publish desktop builds as release assets, use the release download URL as the manifest `feedUrl`.
|
||||
|
||||
Examples:
|
||||
|
||||
- GitHub tag `v1.2.3`:
|
||||
|
||||
`https://github.com/OWNER/REPO/releases/download/v1.2.3`
|
||||
|
||||
- Gitea tag `v1.2.3`:
|
||||
|
||||
`https://gitea.example.com/OWNER/REPO/releases/download/v1.2.3`
|
||||
|
||||
That release must include these assets with their normal Electron Builder names:
|
||||
|
||||
- `latest.yml`
|
||||
- `latest-mac.yml`
|
||||
- `latest-linux.yml`
|
||||
- Windows installer assets (`.exe`, `.blockmap`)
|
||||
- macOS assets (`.dmg`, `.zip`)
|
||||
- Linux assets (`.AppImage`, `.deb`)
|
||||
|
||||
You should also upload `release-manifest.json` as a release asset.
|
||||
|
||||
For a stable manifest URL, point the server at the latest-release asset URL:
|
||||
|
||||
- GitHub:
|
||||
|
||||
`https://github.com/OWNER/REPO/releases/latest/download/release-manifest.json`
|
||||
|
||||
- Gitea: use the equivalent latest-release asset URL if your instance supports it, otherwise publish `release-manifest.json` at a separate stable URL.
|
||||
|
||||
If you want the in-app "Specific version" option to list older releases too, keep one cumulative manifest and merge the previous file when generating the next one:
|
||||
|
||||
`npm run release:manifest -- --existing ./release-manifest.json --feed-url https://github.com/OWNER/REPO/releases/download/v1.2.3 --version 1.2.3`
|
||||
|
||||
The manifest format is:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": "2026-03-10T12:00:00.000Z",
|
||||
"minimumServerVersion": "1.0.0",
|
||||
"pollIntervalMinutes": 30,
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.2.3",
|
||||
"feedUrl": "https://your-cdn.example.com/metoyou/1.2.3",
|
||||
"publishedAt": "2026-03-10T12:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`feedUrl` must point to a directory that contains the Electron Builder update descriptors for Windows, macOS, and Linux.
|
||||
|
||||
### Automated Gitea release queue
|
||||
|
||||
The Gitea workflows in `.gitea/workflows/release-draft.yml` and `.gitea/workflows/publish-draft-release.yml` keep the existing desktop auto-update flow intact.
|
||||
|
||||
On every push to `main` or `master`, the release workflow:
|
||||
|
||||
1. Computes a semver release version from the current `package.json` major/minor version and the workflow run number.
|
||||
2. Builds the Linux and Windows Electron packages.
|
||||
3. Builds standalone server executables for Linux and Windows.
|
||||
4. Downloads the latest published `release-manifest.json`, merges the new release feed URL, and uploads the updated manifest to the draft release.
|
||||
5. Uploads the desktop installers, update descriptors, server executables, and `release-manifest.json` to the matching Gitea release page.
|
||||
|
||||
The draft release uses the standard Gitea download path as its `feedUrl`:
|
||||
|
||||
`https://YOUR_GITEA_HOST/OWNER/REPO/releases/download/vX.Y.Z`
|
||||
|
||||
That means the current desktop auto-updater keeps working without any client-side changes once the draft release is approved and published.
|
||||
|
||||
To enable the workflow:
|
||||
|
||||
- Add a repository secret named `GITEA_RELEASE_TOKEN` with permission to create releases and upload release assets.
|
||||
- Make sure your Gitea runner labels match the workflow `runs-on` values (`linux` and `windows`).
|
||||
- After the draft release is reviewed, publish it either from the Gitea release page or by running the `Publish Draft Release` workflow with the queued release tag.
|
||||
- `server/data/variables.json` can now also hold optional `serverHost` (an IP address or hostname to bind to)
|
||||
- `server/data/variables.json` can now also hold `serverProtocol` (`http` or `https`)
|
||||
- `server/data/variables.json` can now also hold `serverPort` (1-65535)
|
||||
- When `serverProtocol` is `https`, the certificate must match the configured `serverHost` or IP
|
||||
|
||||
## Main commands
|
||||
|
||||
|
||||
2
dev.sh
@@ -33,4 +33,4 @@ fi
|
||||
exec npx concurrently --kill-others \
|
||||
"cd server && npm run dev" \
|
||||
"$NG_SERVE" \
|
||||
"wait-on $WAIT_URL $HEALTH_URL && cross-env NODE_ENV=development SSL=$SSL electron . --no-sandbox --disable-dev-shm-usage"
|
||||
"wait-on $WAIT_URL $HEALTH_URL && cross-env NODE_ENV=development SSL=$SSL node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage"
|
||||
|
||||
@@ -3,7 +3,6 @@ import { readDesktopSettings } from '../desktop-settings';
|
||||
|
||||
export function configureAppFlags(): void {
|
||||
linuxSpecificFlags();
|
||||
audioFlags();
|
||||
networkFlags();
|
||||
setupGpuEncodingFlags();
|
||||
chromiumFlags();
|
||||
@@ -15,21 +14,40 @@ function chromiumFlags(): void {
|
||||
|
||||
// Suppress Autofill devtools errors
|
||||
app.commandLine.appendSwitch('disable-features', 'Autofill,AutofillAssistant,AutofillServerCommunication');
|
||||
}
|
||||
|
||||
function audioFlags(): void {
|
||||
// Collect all enabled features into a single switch to avoid later calls overwriting earlier ones
|
||||
const enabledFeatures: string[] = [];
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
// Use the new PipeWire-based audio pipeline on Linux for better screen share audio capture support
|
||||
app.commandLine.appendSwitch('enable-features', 'AudioServiceOutOfProcess');
|
||||
// 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');
|
||||
}
|
||||
|
||||
const desktopSettings = readDesktopSettings();
|
||||
|
||||
if (process.platform === 'linux' && desktopSettings.vaapiVideoEncode) {
|
||||
enabledFeatures.push('VaapiVideoEncode');
|
||||
}
|
||||
|
||||
if (enabledFeatures.length > 0) {
|
||||
app.commandLine.appendSwitch('enable-features', enabledFeatures.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
function linuxSpecificFlags(): void {
|
||||
// Disable sandbox on Linux to avoid SUID / /tmp shared-memory issues
|
||||
if (process.platform === 'linux') {
|
||||
app.commandLine.appendSwitch('no-sandbox');
|
||||
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
||||
if (process.platform !== 'linux') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable sandbox on Linux to avoid SUID / /tmp shared-memory issues
|
||||
app.commandLine.appendSwitch('no-sandbox');
|
||||
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
||||
|
||||
// Chromium chooses the Linux Ozone platform before Electron runs this file.
|
||||
// The launch scripts pass `--ozone-platform=wayland` up front for Wayland
|
||||
// sessions so the browser process selects the correct backend early enough.
|
||||
}
|
||||
|
||||
function networkFlags(): void {
|
||||
@@ -46,11 +64,6 @@ function setupGpuEncodingFlags(): void {
|
||||
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-zero-copy');
|
||||
app.commandLine.appendSwitch('enable-native-gpu-memory-buffers');
|
||||
|
||||
@@ -32,6 +32,11 @@ interface SinkInputDetails extends ShortSinkInputEntry {
|
||||
properties: Record<string, string>;
|
||||
}
|
||||
|
||||
interface DescendantProcessInfo {
|
||||
ids: ReadonlySet<string>;
|
||||
binaryNames: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
interface PactlJsonSinkInputEntry {
|
||||
index?: number | string;
|
||||
properties?: Record<string, unknown>;
|
||||
@@ -44,6 +49,7 @@ interface LinuxScreenShareAudioRoutingState {
|
||||
screenShareLoopbackModuleId: string | null;
|
||||
voiceLoopbackModuleId: string | null;
|
||||
rerouteIntervalId: ReturnType<typeof setInterval> | null;
|
||||
subscribeProcess: ChildProcess | null;
|
||||
}
|
||||
|
||||
interface LinuxScreenShareMonitorCaptureState {
|
||||
@@ -77,7 +83,8 @@ const routingState: LinuxScreenShareAudioRoutingState = {
|
||||
restoreSinkName: null,
|
||||
screenShareLoopbackModuleId: null,
|
||||
voiceLoopbackModuleId: null,
|
||||
rerouteIntervalId: null
|
||||
rerouteIntervalId: null,
|
||||
subscribeProcess: null
|
||||
};
|
||||
const monitorCaptureState: LinuxScreenShareMonitorCaptureState = {
|
||||
captureId: null,
|
||||
@@ -126,12 +133,21 @@ export async function activateLinuxScreenShareAudioRouting(): Promise<LinuxScree
|
||||
routingState.screenShareLoopbackModuleId = await loadLoopbackModule(SCREEN_SHARE_MONITOR_SOURCE_NAME, restoreSinkName);
|
||||
routingState.voiceLoopbackModuleId = await loadLoopbackModule(`${VOICE_SINK_NAME}.monitor`, restoreSinkName);
|
||||
|
||||
await setDefaultSink(SCREEN_SHARE_SINK_NAME);
|
||||
await moveSinkInputs(SCREEN_SHARE_SINK_NAME, (sinkName) => !!sinkName && sinkName !== SCREEN_SHARE_SINK_NAME && sinkName !== VOICE_SINK_NAME);
|
||||
// Set the default sink to the voice sink so that new app audio
|
||||
// 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;
|
||||
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();
|
||||
startSubscribeWatcher();
|
||||
|
||||
return buildRoutingInfo(true, true);
|
||||
} catch (error) {
|
||||
@@ -148,6 +164,7 @@ export async function activateLinuxScreenShareAudioRouting(): Promise<LinuxScree
|
||||
export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean> {
|
||||
const restoreSinkName = routingState.restoreSinkName;
|
||||
|
||||
stopSubscribeWatcher();
|
||||
stopSinkInputRerouteLoop();
|
||||
await stopLinuxScreenShareMonitorCapture();
|
||||
|
||||
@@ -166,6 +183,7 @@ export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean>
|
||||
routingState.restoreSinkName = null;
|
||||
routingState.screenShareLoopbackModuleId = null;
|
||||
routingState.voiceLoopbackModuleId = null;
|
||||
routingState.subscribeProcess = null;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -425,34 +443,52 @@ async function setDefaultSink(sinkName: string): Promise<void> {
|
||||
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 [
|
||||
sinks,
|
||||
sinkInputs,
|
||||
descendantProcessIds
|
||||
descendantProcessInfo
|
||||
] = await Promise.all([
|
||||
listSinks(),
|
||||
listSinkInputDetails(),
|
||||
collectDescendantProcessIds(process.pid)
|
||||
collectDescendantProcessInfo(process.pid)
|
||||
]);
|
||||
const sinkNamesByIndex = new Map(sinks.map((sink) => [sink.index, sink.name]));
|
||||
|
||||
await Promise.all(
|
||||
sinkInputs.map(async (sinkInput) => {
|
||||
if (!isAppOwnedSinkInput(sinkInput, descendantProcessIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sinkName = sinkNamesByIndex.get(sinkInput.sinkIndex) ?? null;
|
||||
const appOwned = isAppOwnedSinkInput(sinkInput, descendantProcessInfo);
|
||||
|
||||
// App-owned streams must stay on the voice sink.
|
||||
if (appOwned && sinkName !== VOICE_SINK_NAME) {
|
||||
try {
|
||||
await runPactl('move-sink-input', sinkInput.index, VOICE_SINK_NAME);
|
||||
} catch {
|
||||
// Streams can disappear or be recreated while rerouting.
|
||||
}
|
||||
|
||||
if (sinkName === VOICE_SINK_NAME) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await runPactl('move-sink-input', sinkInput.index, VOICE_SINK_NAME);
|
||||
} catch {
|
||||
// Streams can disappear or be recreated while rerouting.
|
||||
// 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(() => {
|
||||
void rerouteAppSinkInputsToVoiceSink();
|
||||
void rerouteSinkInputs();
|
||||
}, REROUTE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
@@ -528,13 +564,108 @@ function stopSinkInputRerouteLoop(): void {
|
||||
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(
|
||||
sinkInput: SinkInputDetails,
|
||||
descendantProcessIds: ReadonlySet<string>
|
||||
descendantProcessInfo: DescendantProcessInfo
|
||||
): boolean {
|
||||
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(
|
||||
@@ -697,31 +828,45 @@ async function listSinkInputDetails(): Promise<SinkInputDetails[]> {
|
||||
return entries.filter((entry) => !!entry.sinkIndex);
|
||||
}
|
||||
|
||||
async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<string>> {
|
||||
const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid='], {
|
||||
async function collectDescendantProcessInfo(rootProcessId: number): Promise<DescendantProcessInfo> {
|
||||
const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid=,comm='], {
|
||||
env: process.env
|
||||
});
|
||||
const childrenByParentId = new Map<string, string[]>();
|
||||
const binaryNameByProcessId = new Map<string, string>();
|
||||
|
||||
stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((line) => {
|
||||
const [pid, ppid] = line.split(/\s+/);
|
||||
const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
||||
|
||||
if (!pid || !ppid) {
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [
|
||||
,
|
||||
pid,
|
||||
ppid,
|
||||
command
|
||||
] = match;
|
||||
const siblings = childrenByParentId.get(ppid) ?? [];
|
||||
|
||||
siblings.push(pid);
|
||||
childrenByParentId.set(ppid, siblings);
|
||||
|
||||
const normalizedBinaryName = normalizeProcessBinary(command);
|
||||
|
||||
if (normalizedBinaryName) {
|
||||
binaryNameByProcessId.set(pid, normalizedBinaryName);
|
||||
}
|
||||
});
|
||||
|
||||
const rootId = `${rootProcessId}`;
|
||||
const descendantIds = new Set<string>([rootId]);
|
||||
const descendantBinaryNames = new Set<string>();
|
||||
const queue = [rootId];
|
||||
|
||||
while (queue.length > 0) {
|
||||
@@ -731,6 +876,12 @@ async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<s
|
||||
continue;
|
||||
}
|
||||
|
||||
const binaryName = binaryNameByProcessId.get(currentId);
|
||||
|
||||
if (binaryName) {
|
||||
descendantBinaryNames.add(binaryName);
|
||||
}
|
||||
|
||||
for (const childId of childrenByParentId.get(currentId) ?? []) {
|
||||
if (descendantIds.has(childId)) {
|
||||
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 {
|
||||
|
||||
@@ -83,6 +83,57 @@ interface ClipboardFilePayload {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
function resolveLinuxDisplayServer(): string {
|
||||
if (process.platform !== 'linux') {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
const ozonePlatform = app.commandLine.getSwitchValue('ozone-platform')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (ozonePlatform === 'wayland') {
|
||||
return 'Wayland';
|
||||
}
|
||||
|
||||
if (ozonePlatform === 'x11') {
|
||||
return 'X11';
|
||||
}
|
||||
|
||||
const ozonePlatformHint = app.commandLine.getSwitchValue('ozone-platform-hint')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (ozonePlatformHint === 'wayland') {
|
||||
return 'Wayland';
|
||||
}
|
||||
|
||||
if (ozonePlatformHint === 'x11') {
|
||||
return 'X11';
|
||||
}
|
||||
|
||||
const sessionType = String(process.env['XDG_SESSION_TYPE'] || '').trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (sessionType === 'wayland') {
|
||||
return 'Wayland';
|
||||
}
|
||||
|
||||
if (sessionType === 'x11') {
|
||||
return 'X11';
|
||||
}
|
||||
|
||||
if (String(process.env['WAYLAND_DISPLAY'] || '').trim().length > 0) {
|
||||
return 'Wayland';
|
||||
}
|
||||
|
||||
if (String(process.env['DISPLAY'] || '').trim().length > 0) {
|
||||
return 'X11';
|
||||
}
|
||||
|
||||
return 'Unknown (Linux)';
|
||||
}
|
||||
|
||||
function isSupportedClipboardFileFormat(format: string): boolean {
|
||||
return FILE_CLIPBOARD_FORMATS.some(
|
||||
(supportedFormat) => supportedFormat.toLowerCase() === format.toLowerCase()
|
||||
@@ -194,6 +245,10 @@ async function readClipboardFiles(): Promise<ClipboardFilePayload[]> {
|
||||
}
|
||||
|
||||
export function setupSystemHandlers(): void {
|
||||
ipcMain.on('get-linux-display-server', (event) => {
|
||||
event.returnValue = resolveLinuxDisplayServer();
|
||||
});
|
||||
|
||||
ipcMain.handle('open-external', async (_event, url: string) => {
|
||||
if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) {
|
||||
await shell.openExternal(url);
|
||||
@@ -204,16 +259,32 @@ export function setupSystemHandlers(): void {
|
||||
});
|
||||
|
||||
ipcMain.handle('get-sources', async () => {
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['window', 'screen'],
|
||||
thumbnailSize: { width: 150, height: 150 }
|
||||
});
|
||||
try {
|
||||
const thumbnailSize = { width: 240, height: 150 };
|
||||
const [screenSources, windowSources] = await Promise.all([
|
||||
desktopCapturer.getSources({
|
||||
types: ['screen'],
|
||||
thumbnailSize
|
||||
}),
|
||||
desktopCapturer.getSources({
|
||||
types: ['window'],
|
||||
thumbnailSize,
|
||||
fetchWindowIcons: true
|
||||
})
|
||||
]);
|
||||
const sources = [...screenSources, ...windowSources];
|
||||
const uniqueSources = new Map(sources.map((source) => [source.id, source]));
|
||||
|
||||
return sources.map((source) => ({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
thumbnail: source.thumbnail.toDataURL()
|
||||
}));
|
||||
return [...uniqueSources.values()].map((source) => ({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
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 () => {
|
||||
|
||||
@@ -83,7 +83,24 @@ export interface DesktopUpdateState {
|
||||
targetVersion: string | null;
|
||||
}
|
||||
|
||||
function readLinuxDisplayServer(): string {
|
||||
if (process.platform !== 'linux') {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
try {
|
||||
const displayServer = ipcRenderer.sendSync('get-linux-display-server');
|
||||
|
||||
return typeof displayServer === 'string' && displayServer.trim().length > 0
|
||||
? displayServer
|
||||
: 'Unknown (Linux)';
|
||||
} catch {
|
||||
return 'Unknown (Linux)';
|
||||
}
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
linuxDisplayServer: string;
|
||||
minimizeWindow: () => void;
|
||||
maximizeWindow: () => void;
|
||||
closeWindow: () => void;
|
||||
@@ -139,6 +156,7 @@ export interface ElectronAPI {
|
||||
}
|
||||
|
||||
const electronAPI: ElectronAPI = {
|
||||
linuxDisplayServer: readLinuxDisplayServer(),
|
||||
minimizeWindow: () => ipcRenderer.send('window-minimize'),
|
||||
maximizeWindow: () => ipcRenderer.send('window-maximize'),
|
||||
closeWindow: () => ipcRenderer.send('window-close'),
|
||||
|
||||
@@ -729,7 +729,7 @@ export function restartToApplyUpdate(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
autoUpdater.quitAndInstall(true, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
desktopCapturer,
|
||||
session,
|
||||
shell
|
||||
} from 'electron';
|
||||
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') {
|
||||
const devUrl = process.env['SSL'] === 'true'
|
||||
? 'https://localhost:4200'
|
||||
|
||||
14
package.json
@@ -11,20 +11,20 @@
|
||||
"prebuild": "npm run bundle:rnnoise",
|
||||
"prestart": "npm run bundle:rnnoise",
|
||||
"bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js",
|
||||
"start": "npm run ng serve",
|
||||
"build": "npm run ng build",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"build:electron": "tsc -p tsconfig.electron.json",
|
||||
"build:all": "npm run build && npm run build:electron && cd server && npm run build",
|
||||
"build:prod": "npm run ng build --configuration production --base-href='./'",
|
||||
"build:prod": "ng build --configuration production --base-href='./'",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test",
|
||||
"server:build": "cd server && npm run build",
|
||||
"server:start": "cd server && npm start",
|
||||
"server:dev": "cd server && npm run dev",
|
||||
"electron": "npm run ng build && npm run build:electron && electron . --no-sandbox --disable-dev-shm-usage",
|
||||
"electron:dev": "concurrently \"npm run ng serve\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development electron . --no-sandbox --disable-dev-shm-usage\"",
|
||||
"electron": "ng build && npm run build:electron && node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage",
|
||||
"electron:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
|
||||
"electron:full": "./dev.sh",
|
||||
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production electron . --no-sandbox --disable-dev-shm-usage\"",
|
||||
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
|
||||
"migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js",
|
||||
"migration:create": "typeorm migration:create electron/migrations/New",
|
||||
"migration:run": "typeorm migration:run -d dist/electron/data-source.js",
|
||||
@@ -36,7 +36,7 @@
|
||||
"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: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",
|
||||
"lint": "eslint .",
|
||||
"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>
|
||||
@@ -2,13 +2,20 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { resolveRuntimePath } from '../runtime-paths';
|
||||
|
||||
export type ServerHttpProtocol = 'http' | 'https';
|
||||
|
||||
export interface ServerVariablesConfig {
|
||||
klipyApiKey: string;
|
||||
releaseManifestUrl: string;
|
||||
serverPort: number;
|
||||
serverProtocol: ServerHttpProtocol;
|
||||
serverHost: string;
|
||||
}
|
||||
|
||||
const DATA_DIR = resolveRuntimePath('data');
|
||||
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
|
||||
const DEFAULT_SERVER_PORT = 3001;
|
||||
const DEFAULT_SERVER_PROTOCOL: ServerHttpProtocol = 'http';
|
||||
|
||||
function normalizeKlipyApiKey(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
@@ -18,6 +25,51 @@ function normalizeReleaseManifestUrl(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeServerHost(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeServerProtocol(
|
||||
value: unknown,
|
||||
fallback: ServerHttpProtocol = DEFAULT_SERVER_PROTOCOL
|
||||
): ServerHttpProtocol {
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'https' : 'http';
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
|
||||
if (normalized === 'https' || normalized === 'true') {
|
||||
return 'https';
|
||||
}
|
||||
|
||||
if (normalized === 'http' || normalized === 'false') {
|
||||
return 'http';
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeServerPort(value: unknown, fallback = DEFAULT_SERVER_PORT): number {
|
||||
const parsed = typeof value === 'number'
|
||||
? value
|
||||
: typeof value === 'string'
|
||||
? Number.parseInt(value.trim(), 10)
|
||||
: Number.NaN;
|
||||
|
||||
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535
|
||||
? parsed
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function hasEnvironmentOverride(value: string | undefined): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function readRawVariables(): { rawContents: string; parsed: Record<string, unknown> } {
|
||||
if (!fs.existsSync(VARIABLES_FILE)) {
|
||||
return { rawContents: '', parsed: {} };
|
||||
@@ -52,10 +104,14 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
||||
}
|
||||
|
||||
const { rawContents, parsed } = readRawVariables();
|
||||
const { serverIpAddress: legacyServerIpAddress, ...remainingParsed } = parsed;
|
||||
const normalized = {
|
||||
...parsed,
|
||||
klipyApiKey: normalizeKlipyApiKey(parsed.klipyApiKey),
|
||||
releaseManifestUrl: normalizeReleaseManifestUrl(parsed.releaseManifestUrl)
|
||||
...remainingParsed,
|
||||
klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey),
|
||||
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
|
||||
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
||||
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
||||
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress)
|
||||
};
|
||||
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
|
||||
|
||||
@@ -65,7 +121,10 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
||||
|
||||
return {
|
||||
klipyApiKey: normalized.klipyApiKey,
|
||||
releaseManifestUrl: normalized.releaseManifestUrl
|
||||
releaseManifestUrl: normalized.releaseManifestUrl,
|
||||
serverPort: normalized.serverPort,
|
||||
serverProtocol: normalized.serverProtocol,
|
||||
serverHost: normalized.serverHost
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,3 +143,29 @@ export function hasKlipyApiKey(): boolean {
|
||||
export function getReleaseManifestUrl(): string {
|
||||
return getVariablesConfig().releaseManifestUrl;
|
||||
}
|
||||
|
||||
export function getServerProtocol(): ServerHttpProtocol {
|
||||
if (hasEnvironmentOverride(process.env.SSL)) {
|
||||
return normalizeServerProtocol(process.env.SSL);
|
||||
}
|
||||
|
||||
return getVariablesConfig().serverProtocol;
|
||||
}
|
||||
|
||||
export function getServerPort(): number {
|
||||
if (hasEnvironmentOverride(process.env.PORT)) {
|
||||
return normalizeServerPort(process.env.PORT);
|
||||
}
|
||||
|
||||
return getVariablesConfig().serverPort;
|
||||
}
|
||||
|
||||
export function getServerHost(): string | undefined {
|
||||
const serverHost = getVariablesConfig().serverHost;
|
||||
|
||||
return serverHost || undefined;
|
||||
}
|
||||
|
||||
export function isHttpsServerEnabled(): boolean {
|
||||
return getServerProtocol() === 'https';
|
||||
}
|
||||
|
||||
@@ -14,23 +14,39 @@ import { deleteStaleJoinRequests } from './cqrs';
|
||||
import { createApp } from './app';
|
||||
import {
|
||||
ensureVariablesConfig,
|
||||
getServerHost,
|
||||
getVariablesConfigPath,
|
||||
hasKlipyApiKey
|
||||
getServerPort,
|
||||
getServerProtocol,
|
||||
ServerHttpProtocol
|
||||
} from './config/variables';
|
||||
import { setupWebSocket } from './websocket';
|
||||
|
||||
const USE_SSL = (process.env.SSL ?? 'false').toLowerCase() === 'true';
|
||||
const PORT = process.env.PORT || 3001;
|
||||
function formatHostForUrl(host: string): string {
|
||||
if (host.startsWith('[') || !host.includes(':')) {
|
||||
return host;
|
||||
}
|
||||
|
||||
function buildServer(app: ReturnType<typeof createApp>) {
|
||||
if (USE_SSL) {
|
||||
return `[${host}]`;
|
||||
}
|
||||
|
||||
function getDisplayHost(serverHost: string | undefined): string {
|
||||
if (!serverHost || serverHost === '0.0.0.0' || serverHost === '::') {
|
||||
return 'localhost';
|
||||
}
|
||||
|
||||
return serverHost;
|
||||
}
|
||||
|
||||
function buildServer(app: ReturnType<typeof createApp>, serverProtocol: ServerHttpProtocol) {
|
||||
if (serverProtocol === 'https') {
|
||||
const certDir = resolveCertificateDirectory();
|
||||
const certFile = path.join(certDir, 'localhost.crt');
|
||||
const keyFile = path.join(certDir, 'localhost.key');
|
||||
|
||||
if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) {
|
||||
console.error(`SSL=true but certs not found in ${certDir}`);
|
||||
console.error('Run ./generate-cert.sh first.');
|
||||
console.error(`HTTPS is enabled but certs were not found in ${certDir}`);
|
||||
console.error('Add localhost.crt and localhost.key there, or switch serverProtocol to "http".');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -44,17 +60,31 @@ function buildServer(app: ReturnType<typeof createApp>) {
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
ensureVariablesConfig();
|
||||
const variablesConfig = ensureVariablesConfig();
|
||||
const serverProtocol = getServerProtocol();
|
||||
const serverPort = getServerPort();
|
||||
const serverHost = getServerHost();
|
||||
const bindHostLabel = serverHost || 'default interface';
|
||||
|
||||
console.log('[Config] Variables loaded from:', getVariablesConfigPath());
|
||||
|
||||
if (!hasKlipyApiKey()) {
|
||||
if (
|
||||
variablesConfig.serverProtocol !== serverProtocol
|
||||
|| variablesConfig.serverPort !== serverPort
|
||||
) {
|
||||
console.log(`[Config] Server runtime override active: protocol=${serverProtocol}, host=${bindHostLabel}, port=${serverPort}`);
|
||||
} else {
|
||||
console.log(`[Config] Server runtime config: protocol=${serverProtocol}, host=${bindHostLabel}, port=${serverPort}`);
|
||||
}
|
||||
|
||||
if (!variablesConfig.klipyApiKey) {
|
||||
console.log('[KLIPY] API key not configured. GIF search is disabled.');
|
||||
}
|
||||
|
||||
await initDatabase();
|
||||
|
||||
const app = createApp();
|
||||
const server = buildServer(app);
|
||||
const server = buildServer(app, serverProtocol);
|
||||
|
||||
setupWebSocket(server);
|
||||
|
||||
@@ -64,14 +94,24 @@ async function bootstrap(): Promise<void> {
|
||||
.catch(err => console.error('Failed to clean up stale join requests:', err));
|
||||
}, 60 * 1000);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
const proto = USE_SSL ? 'https' : 'http';
|
||||
const wsProto = USE_SSL ? 'wss' : 'ws';
|
||||
const onListening = () => {
|
||||
const displayHost = formatHostForUrl(getDisplayHost(serverHost));
|
||||
const wsProto = serverProtocol === 'https' ? 'wss' : 'ws';
|
||||
|
||||
console.log(`MetoYou signaling server running on port ${PORT} (SSL=${USE_SSL})`);
|
||||
console.log(` REST API: ${proto}://localhost:${PORT}/api`);
|
||||
console.log(` WebSocket: ${wsProto}://localhost:${PORT}`);
|
||||
});
|
||||
console.log(`MetoYou signaling server running on port ${serverPort} (${serverProtocol.toUpperCase()}, bind host=${bindHostLabel})`);
|
||||
console.log(` REST API: ${serverProtocol}://${displayHost}:${serverPort}/api`);
|
||||
console.log(` WebSocket: ${wsProto}://${displayHost}:${serverPort}`);
|
||||
|
||||
if (serverProtocol === 'https' && serverHost && !['localhost', '127.0.0.1', '::1'].includes(serverHost)) {
|
||||
console.warn('[Config] HTTPS certificates must match the configured serverHost/server IP.');
|
||||
}
|
||||
};
|
||||
|
||||
if (serverHost) {
|
||||
server.listen(serverPort, serverHost, onListening);
|
||||
} else {
|
||||
server.listen(serverPort, onListening);
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap().catch((err) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ interface WsMessage {
|
||||
/** Sends the current user list for a given server to a single connected user. */
|
||||
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||
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' }));
|
||||
|
||||
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
||||
|
||||
@@ -9,13 +9,73 @@ import { connectedUsers } from './state';
|
||||
import { broadcastToServer } from './broadcast';
|
||||
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 {
|
||||
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) => {
|
||||
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) => {
|
||||
try {
|
||||
@@ -28,20 +88,7 @@ export function setupWebSocket(server: Server<typeof IncomingMessage, typeof Ser
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
const user = connectedUsers.get(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);
|
||||
removeDeadConnection(connectionId);
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({ type: 'connected', connectionId, serverTime: Date.now() }));
|
||||
|
||||
@@ -6,4 +6,6 @@ export interface ConnectedUser {
|
||||
serverIds: Set<string>;
|
||||
viewedServerId?: string;
|
||||
displayName?: string;
|
||||
/** Timestamp of the last pong received (used to detect dead connections). */
|
||||
lastPong: number;
|
||||
}
|
||||
|
||||
@@ -94,5 +94,8 @@
|
||||
<!-- Unified Settings Modal -->
|
||||
<app-settings-modal />
|
||||
|
||||
<!-- Shared Screen Share Source Picker -->
|
||||
<app-screen-share-source-picker />
|
||||
|
||||
<!-- Shared Debug Console -->
|
||||
<app-debug-console [showLauncher]="false" />
|
||||
|
||||
@@ -25,6 +25,7 @@ import { TitleBarComponent } from './features/shell/title-bar.component';
|
||||
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component';
|
||||
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
|
||||
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
||||
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
||||
import { UsersActions } from './store/users/users.actions';
|
||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||
@@ -43,7 +44,8 @@ import {
|
||||
TitleBarComponent,
|
||||
FloatingVoiceControlsComponent,
|
||||
SettingsModalComponent,
|
||||
DebugConsoleComponent
|
||||
DebugConsoleComponent,
|
||||
ScreenShareSourcePickerComponent
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss'
|
||||
|
||||
@@ -218,7 +218,16 @@ export class DebuggingService {
|
||||
|
||||
const rawMessage = args.map((arg) => this.stringifyPreview(arg)).join(' ')
|
||||
.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 payloadText = payload === undefined
|
||||
? null
|
||||
|
||||
134
src/app/core/services/screen-share-source-picker.service.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from './voice-settings.storage';
|
||||
import { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from './webrtc/webrtc.constants';
|
||||
|
||||
export type ScreenShareSourceKind = 'screen' | 'window';
|
||||
|
||||
export interface ScreenShareSourceOption {
|
||||
id: string;
|
||||
kind: ScreenShareSourceKind;
|
||||
name: string;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
export interface ScreenShareSourceSelection {
|
||||
includeSystemAudio: boolean;
|
||||
source: ScreenShareSourceOption;
|
||||
}
|
||||
|
||||
interface ScreenShareSourcePickerRequest {
|
||||
includeSystemAudio: boolean;
|
||||
sources: readonly ScreenShareSourceOption[];
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ScreenShareSourcePickerService {
|
||||
readonly request = computed(() => this._request());
|
||||
|
||||
private readonly _request = signal<ScreenShareSourcePickerRequest | null>(null);
|
||||
|
||||
private pendingResolve: ((selection: ScreenShareSourceSelection) => void) | null = null;
|
||||
private pendingReject: ((reason?: unknown) => void) | null = null;
|
||||
|
||||
open(
|
||||
sources: readonly Pick<ScreenShareSourceOption, 'id' | 'name' | 'thumbnail'>[],
|
||||
initialIncludeSystemAudio = loadVoiceSettingsFromStorage().includeSystemAudio
|
||||
): Promise<ScreenShareSourceSelection> {
|
||||
if (sources.length === 0) {
|
||||
throw new Error('No desktop capture sources were available.');
|
||||
}
|
||||
|
||||
this.cancelPendingRequest();
|
||||
|
||||
const normalizedSources = sources.map((source) => {
|
||||
const kind = this.getSourceKind(source);
|
||||
|
||||
return {
|
||||
...source,
|
||||
kind,
|
||||
name: this.getSourceDisplayName(source.name, kind)
|
||||
};
|
||||
});
|
||||
|
||||
this._request.set({
|
||||
includeSystemAudio: initialIncludeSystemAudio,
|
||||
sources: normalizedSources
|
||||
});
|
||||
|
||||
return new Promise<ScreenShareSourceSelection>((resolve, reject) => {
|
||||
this.pendingResolve = resolve;
|
||||
this.pendingReject = reject;
|
||||
});
|
||||
}
|
||||
|
||||
confirm(sourceId: string, includeSystemAudio: boolean): void {
|
||||
const activeRequest = this._request();
|
||||
const source = activeRequest?.sources.find((entry) => entry.id === sourceId);
|
||||
const resolve = this.pendingResolve;
|
||||
|
||||
if (!source || !resolve) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearPendingRequest();
|
||||
saveVoiceSettingsToStorage({ includeSystemAudio });
|
||||
resolve({
|
||||
includeSystemAudio,
|
||||
source
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.cancelPendingRequest();
|
||||
}
|
||||
|
||||
private cancelPendingRequest(): void {
|
||||
const reject = this.pendingReject;
|
||||
|
||||
this.clearPendingRequest();
|
||||
|
||||
if (reject) {
|
||||
reject(this.createAbortError());
|
||||
}
|
||||
}
|
||||
|
||||
private clearPendingRequest(): void {
|
||||
this._request.set(null);
|
||||
this.pendingResolve = null;
|
||||
this.pendingReject = null;
|
||||
}
|
||||
|
||||
private getSourceKind(
|
||||
source: Pick<ScreenShareSourceOption, 'id' | 'name'>
|
||||
): ScreenShareSourceKind {
|
||||
return source.id.startsWith('screen') || source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME
|
||||
? 'screen'
|
||||
: 'window';
|
||||
}
|
||||
|
||||
private getSourceDisplayName(name: string, kind: ScreenShareSourceKind): string {
|
||||
const trimmedName = name.trim();
|
||||
|
||||
if (trimmedName) {
|
||||
return trimmedName;
|
||||
}
|
||||
|
||||
return kind === 'screen' ? 'Entire screen' : 'Window';
|
||||
}
|
||||
|
||||
private createAbortError(): Error {
|
||||
if (typeof DOMException !== 'undefined') {
|
||||
return new DOMException('The user aborted a request.', 'AbortError');
|
||||
}
|
||||
|
||||
const error = new Error('The user aborted a request.');
|
||||
|
||||
error.name = 'AbortError';
|
||||
|
||||
return error;
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ function buildDefaultServerUrl(): string {
|
||||
|
||||
/** Blueprint for the built-in default endpoint. */
|
||||
const DEFAULT_ENDPOINT: Omit<ServerEndpoint, 'id'> = {
|
||||
name: 'Local Server',
|
||||
name: 'Default Server',
|
||||
url: buildDefaultServerUrl(),
|
||||
isActive: true,
|
||||
isDefault: true,
|
||||
|
||||
@@ -24,6 +24,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { SignalingMessage, ChatEvent } from '../models/index';
|
||||
import { TimeSyncService } from './time-sync.service';
|
||||
import { DebuggingService } from './debugging.service';
|
||||
import { ScreenShareSourcePickerService } from './screen-share-source-picker.service';
|
||||
|
||||
import {
|
||||
SignalingManager,
|
||||
@@ -81,6 +82,7 @@ type IncomingSignalingMessage = Omit<Partial<SignalingMessage>, 'type' | 'payloa
|
||||
export class WebRTCService implements OnDestroy {
|
||||
private readonly timeSync = inject(TimeSyncService);
|
||||
private readonly debugging = inject(DebuggingService);
|
||||
private readonly screenShareSourcePicker = inject(ScreenShareSourcePickerService);
|
||||
|
||||
private readonly logger = new WebRTCLogger(() => this.debugging.enabled());
|
||||
|
||||
@@ -106,6 +108,8 @@ export class WebRTCService implements OnDestroy {
|
||||
private readonly _isScreenSharing = signal(false);
|
||||
private readonly _isNoiseReductionEnabled = signal(false);
|
||||
private readonly _screenStreamSignal = signal<MediaStream | null>(null);
|
||||
private readonly _isScreenShareRemotePlaybackSuppressed = signal(false);
|
||||
private readonly _forceDefaultRemotePlaybackOutput = signal(false);
|
||||
private readonly _hasConnectionError = signal(false);
|
||||
private readonly _connectionErrorMessage = signal<string | null>(null);
|
||||
private readonly _hasEverConnected = signal(false);
|
||||
@@ -127,6 +131,8 @@ export class WebRTCService implements OnDestroy {
|
||||
readonly isScreenSharing = computed(() => this._isScreenSharing());
|
||||
readonly isNoiseReductionEnabled = computed(() => this._isNoiseReductionEnabled());
|
||||
readonly screenStream = computed(() => this._screenStreamSignal());
|
||||
readonly isScreenShareRemotePlaybackSuppressed = computed(() => this._isScreenShareRemotePlaybackSuppressed());
|
||||
readonly forceDefaultRemotePlaybackOutput = computed(() => this._forceDefaultRemotePlaybackOutput());
|
||||
readonly hasConnectionError = computed(() => this._hasConnectionError());
|
||||
readonly connectionErrorMessage = computed(() => this._connectionErrorMessage());
|
||||
readonly shouldShowConnectionError = computed(() => {
|
||||
@@ -207,7 +213,17 @@ export class WebRTCService implements OnDestroy {
|
||||
this.peerManager.activePeerConnections,
|
||||
getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(),
|
||||
renegotiate: (peerId: string): Promise<void> => this.peerManager.renegotiate(peerId),
|
||||
broadcastCurrentStates: (): void => this.peerManager.broadcastCurrentStates()
|
||||
broadcastCurrentStates: (): void => this.peerManager.broadcastCurrentStates(),
|
||||
selectDesktopSource: async (sources, options) => await this.screenShareSourcePicker.open(
|
||||
sources,
|
||||
options.includeSystemAudio
|
||||
),
|
||||
updateLocalScreenShareState: (state): void => {
|
||||
this._isScreenSharing.set(state.active);
|
||||
this._screenStreamSignal.set(state.stream);
|
||||
this._isScreenShareRemotePlaybackSuppressed.set(state.suppressRemotePlayback);
|
||||
this._forceDefaultRemotePlaybackOutput.set(state.forceDefaultRemotePlaybackOutput);
|
||||
}
|
||||
});
|
||||
|
||||
this.wireManagerEvents();
|
||||
@@ -500,6 +516,11 @@ export class WebRTCService implements OnDestroy {
|
||||
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.
|
||||
*
|
||||
@@ -853,18 +874,12 @@ export class WebRTCService implements OnDestroy {
|
||||
* @returns The screen-capture {@link MediaStream}.
|
||||
*/
|
||||
async startScreenShare(options: ScreenShareStartOptions): Promise<MediaStream> {
|
||||
const stream = await this.screenShareManager.startScreenShare(options);
|
||||
|
||||
this._isScreenSharing.set(true);
|
||||
this._screenStreamSignal.set(stream);
|
||||
return stream;
|
||||
return await this.screenShareManager.startScreenShare(options);
|
||||
}
|
||||
|
||||
/** Stop screen sharing and restore microphone audio on all peers. */
|
||||
stopScreenShare(): void {
|
||||
this.screenShareManager.stopScreenShare();
|
||||
this._isScreenSharing.set(false);
|
||||
this._screenStreamSignal.set(null);
|
||||
}
|
||||
|
||||
/** Disconnect from the signaling server and clean up all state. */
|
||||
@@ -899,6 +914,8 @@ export class WebRTCService implements OnDestroy {
|
||||
this.screenShareManager.stopScreenShare();
|
||||
this._isScreenSharing.set(false);
|
||||
this._screenStreamSignal.set(null);
|
||||
this._isScreenShareRemotePlaybackSuppressed.set(false);
|
||||
this._forceDefaultRemotePlaybackOutput.set(false);
|
||||
}
|
||||
|
||||
/** Synchronise Angular signals from the MediaManager's internal state. */
|
||||
|
||||
@@ -103,10 +103,10 @@ export class MediaManager {
|
||||
* Replace the callback set at runtime.
|
||||
* 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 {
|
||||
this.callbacks = cb;
|
||||
setCallbacks(nextCallbacks: MediaManagerCallbacks): void {
|
||||
this.callbacks = nextCallbacks;
|
||||
}
|
||||
|
||||
/** Returns the current local media stream, or `null` if voice is disabled. */
|
||||
@@ -485,28 +485,21 @@ export class MediaManager {
|
||||
if (!this.localMediaStream)
|
||||
return;
|
||||
|
||||
const localAudioTrack = this.localMediaStream.getAudioTracks()[0] || null;
|
||||
const localVideoTrack = this.localMediaStream.getVideoTracks()[0] || null;
|
||||
const localStream = this.localMediaStream;
|
||||
const localAudioTrack = localStream.getAudioTracks()[0] || null;
|
||||
const localVideoTrack = localStream.getVideoTracks()[0] || null;
|
||||
|
||||
peers.forEach((peerData, peerId) => {
|
||||
if (localAudioTrack) {
|
||||
let audioSender =
|
||||
peerData.audioSender ||
|
||||
peerData.connection.getSenders().find((s) => s.track?.kind === TRACK_KIND_AUDIO);
|
||||
|
||||
if (!audioSender) {
|
||||
audioSender = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, {
|
||||
direction: TRANSCEIVER_SEND_RECV
|
||||
}).sender;
|
||||
}
|
||||
const audioTransceiver = this.getOrCreateReusableTransceiver(peerData, TRACK_KIND_AUDIO, {
|
||||
preferredSender: peerData.audioSender,
|
||||
excludedSenders: [peerData.screenAudioSender]
|
||||
});
|
||||
const audioSender = audioTransceiver.sender;
|
||||
|
||||
peerData.audioSender = audioSender;
|
||||
|
||||
// Restore direction after removeTrack (which sets it to recvonly)
|
||||
const audioTransceiver = peerData.connection
|
||||
.getTransceivers()
|
||||
.find((t) => t.sender === audioSender);
|
||||
|
||||
if (
|
||||
audioTransceiver &&
|
||||
(audioTransceiver.direction === TRANSCEIVER_RECV_ONLY ||
|
||||
@@ -515,29 +508,25 @@ export class MediaManager {
|
||||
audioTransceiver.direction = TRANSCEIVER_SEND_RECV;
|
||||
}
|
||||
|
||||
if (typeof audioSender.setStreams === 'function') {
|
||||
audioSender.setStreams(localStream);
|
||||
}
|
||||
|
||||
audioSender
|
||||
.replaceTrack(localAudioTrack)
|
||||
.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) {
|
||||
let videoSender =
|
||||
peerData.videoSender ||
|
||||
peerData.connection.getSenders().find((s) => s.track?.kind === TRACK_KIND_VIDEO);
|
||||
|
||||
if (!videoSender) {
|
||||
videoSender = peerData.connection.addTransceiver(TRACK_KIND_VIDEO, {
|
||||
direction: TRANSCEIVER_SEND_RECV
|
||||
}).sender;
|
||||
}
|
||||
const videoTransceiver = this.getOrCreateReusableTransceiver(peerData, TRACK_KIND_VIDEO, {
|
||||
preferredSender: peerData.videoSender,
|
||||
excludedSenders: [peerData.screenVideoSender]
|
||||
});
|
||||
const videoSender = videoTransceiver.sender;
|
||||
|
||||
peerData.videoSender = videoSender;
|
||||
|
||||
const videoTransceiver = peerData.connection
|
||||
.getTransceivers()
|
||||
.find((t) => t.sender === videoSender);
|
||||
|
||||
if (
|
||||
videoTransceiver &&
|
||||
(videoTransceiver.direction === TRANSCEIVER_RECV_ONLY ||
|
||||
@@ -546,16 +535,64 @@ export class MediaManager {
|
||||
videoTransceiver.direction = TRANSCEIVER_SEND_RECV;
|
||||
}
|
||||
|
||||
if (typeof videoSender.setStreams === 'function') {
|
||||
videoSender.setStreams(localStream);
|
||||
}
|
||||
|
||||
videoSender
|
||||
.replaceTrack(localVideoTrack)
|
||||
.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);
|
||||
});
|
||||
}
|
||||
|
||||
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. */
|
||||
private broadcastVoicePresence(): void {
|
||||
const oderId = this.callbacks.getIdentifyOderId();
|
||||
|
||||
@@ -127,7 +127,9 @@ export function createPeerConnection(
|
||||
isInitiator,
|
||||
pendingIceCandidates: [],
|
||||
audioSender: undefined,
|
||||
videoSender: undefined
|
||||
videoSender: undefined,
|
||||
remoteVoiceStreamIds: new Set<string>(),
|
||||
remoteScreenShareStreamIds: new Set<string>()
|
||||
};
|
||||
|
||||
if (isInitiator) {
|
||||
@@ -151,6 +153,10 @@ export function createPeerConnection(
|
||||
|
||||
localStream.getTracks().forEach((track) => {
|
||||
if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) {
|
||||
if (typeof peerData.audioSender.setStreams === 'function') {
|
||||
peerData.audioSender.setStreams(localStream);
|
||||
}
|
||||
|
||||
peerData.audioSender
|
||||
.replaceTrack(track)
|
||||
.then(() => logger.info('audio replaceTrack (init) ok', { remotePeerId }))
|
||||
@@ -158,6 +164,10 @@ export function createPeerConnection(
|
||||
logger.error('audio replaceTrack failed at createPeerConnection', error)
|
||||
);
|
||||
} else if (track.kind === TRACK_KIND_VIDEO && peerData.videoSender) {
|
||||
if (typeof peerData.videoSender.setStreams === 'function') {
|
||||
peerData.videoSender.setStreams(localStream);
|
||||
}
|
||||
|
||||
peerData.videoSender
|
||||
.replaceTrack(track)
|
||||
.then(() => logger.info('video replaceTrack (init) ok', { remotePeerId }))
|
||||
|
||||
@@ -9,6 +9,7 @@ export function handleRemoteTrack(
|
||||
): void {
|
||||
const { logger, state } = context;
|
||||
const track = event.track;
|
||||
const isScreenAudio = isScreenShareAudioTrack(context, event, remotePeerId);
|
||||
const settings =
|
||||
typeof track.getSettings === 'function' ? track.getSettings() : ({} as MediaTrackSettings);
|
||||
|
||||
@@ -34,10 +35,10 @@ export function handleRemoteTrack(
|
||||
}
|
||||
|
||||
const compositeStream = buildCompositeRemoteStream(state, remotePeerId, track);
|
||||
const voiceStream = isVoiceAudioTrack(context, event, remotePeerId)
|
||||
const voiceStream = isVoiceAudioTrack(track, isScreenAudio)
|
||||
? buildAudioOnlyStream(state.remotePeerVoiceStreams.get(remotePeerId), track)
|
||||
: null;
|
||||
const screenShareStream = isScreenShareTrack(context, event, remotePeerId)
|
||||
const screenShareStream = isScreenShareTrack(track, isScreenAudio)
|
||||
? buildScreenShareStream(state.remotePeerScreenShareStreams.get(remotePeerId), track)
|
||||
: null;
|
||||
|
||||
@@ -53,6 +54,12 @@ export function handleRemoteTrack(
|
||||
state.remotePeerScreenShareStreams.set(remotePeerId, screenShareStream);
|
||||
}
|
||||
|
||||
rememberIncomingStreamIds(state, event, remotePeerId, {
|
||||
isScreenAudio,
|
||||
isVoiceAudio: !!voiceStream,
|
||||
isScreenTrack: !!screenShareStream
|
||||
});
|
||||
|
||||
publishRemoteStreamUpdate(context, remotePeerId, compositeStream);
|
||||
}
|
||||
|
||||
@@ -61,6 +68,7 @@ export function clearRemoteScreenShareStream(
|
||||
remotePeerId: string
|
||||
): void {
|
||||
const { state } = context;
|
||||
const peerData = state.activePeerConnections.get(remotePeerId);
|
||||
const screenShareStream = state.remotePeerScreenShareStreams.get(remotePeerId);
|
||||
|
||||
if (!screenShareStream) {
|
||||
@@ -79,6 +87,8 @@ export function clearRemoteScreenShareStream(
|
||||
removeTracksFromStreamMap(state.remotePeerVoiceStreams, remotePeerId, screenShareTrackIds);
|
||||
state.remotePeerScreenShareStreams.delete(remotePeerId);
|
||||
|
||||
peerData?.remoteScreenShareStreamIds.clear();
|
||||
|
||||
publishRemoteStreamUpdate(context, remotePeerId, compositeStream);
|
||||
}
|
||||
|
||||
@@ -152,11 +162,20 @@ function removeRemoteTrack(
|
||||
trackId: string
|
||||
): void {
|
||||
const { state } = context;
|
||||
const peerData = state.activePeerConnections.get(remotePeerId);
|
||||
const compositeStream = removeTrackFromStreamMap(state.remotePeerStreams, remotePeerId, trackId);
|
||||
|
||||
removeTrackFromStreamMap(state.remotePeerVoiceStreams, 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);
|
||||
}
|
||||
|
||||
@@ -224,20 +243,12 @@ function publishRemoteStreamUpdate(
|
||||
});
|
||||
}
|
||||
|
||||
function isVoiceAudioTrack(
|
||||
context: PeerConnectionManagerContext,
|
||||
event: RTCTrackEvent,
|
||||
remotePeerId: string
|
||||
): boolean {
|
||||
return event.track.kind === TRACK_KIND_AUDIO && !isScreenShareAudioTrack(context, event, remotePeerId);
|
||||
function isVoiceAudioTrack(track: MediaStreamTrack, isScreenAudio: boolean): boolean {
|
||||
return track.kind === TRACK_KIND_AUDIO && !isScreenAudio;
|
||||
}
|
||||
|
||||
function isScreenShareTrack(
|
||||
context: PeerConnectionManagerContext,
|
||||
event: RTCTrackEvent,
|
||||
remotePeerId: string
|
||||
): boolean {
|
||||
return event.track.kind === TRACK_KIND_VIDEO || isScreenShareAudioTrack(context, event, remotePeerId);
|
||||
function isScreenShareTrack(track: MediaStreamTrack, isScreenAudio: boolean): boolean {
|
||||
return track.kind === TRACK_KIND_VIDEO || isScreenAudio;
|
||||
}
|
||||
|
||||
function isScreenShareAudioTrack(
|
||||
@@ -255,12 +266,34 @@ function isScreenShareAudioTrack(
|
||||
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(
|
||||
(transceiver) => transceiver.sender === peerData.audioSender
|
||||
);
|
||||
|
||||
if (voiceAudioTransceiver) {
|
||||
return event.transceiver !== voiceAudioTransceiver;
|
||||
return !matchesTransceiver(event.transceiver, voiceAudioTransceiver);
|
||||
}
|
||||
|
||||
const audioTransceivers = peerData.connection.getTransceivers().filter((transceiver) =>
|
||||
@@ -272,3 +305,52 @@ function isScreenShareAudioTrack(
|
||||
|
||||
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_VIDEO,
|
||||
TRANSCEIVER_SEND_RECV,
|
||||
TRANSCEIVER_RECV_ONLY,
|
||||
ELECTRON_ENTIRE_SCREEN_SOURCE_NAME
|
||||
TRANSCEIVER_RECV_ONLY
|
||||
} from './webrtc.constants';
|
||||
import {
|
||||
DEFAULT_SCREEN_SHARE_START_OPTIONS,
|
||||
@@ -18,6 +17,10 @@ import {
|
||||
ScreenShareQualityPreset,
|
||||
ScreenShareStartOptions
|
||||
} 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.
|
||||
@@ -27,93 +30,27 @@ export interface ScreenShareCallbacks {
|
||||
getLocalMediaStream(): MediaStream | null;
|
||||
renegotiate(peerId: string): Promise<void>;
|
||||
broadcastCurrentStates(): void;
|
||||
selectDesktopSource?(
|
||||
sources: readonly { id: string; name: string; thumbnail: string }[],
|
||||
options: { includeSystemAudio: boolean }
|
||||
): Promise<{
|
||||
includeSystemAudio: boolean;
|
||||
source: { id: string; name: string; thumbnail: string };
|
||||
}>;
|
||||
updateLocalScreenShareState?(state: LocalScreenShareState): void;
|
||||
}
|
||||
|
||||
interface LinuxScreenShareAudioRoutingInfo {
|
||||
available: boolean;
|
||||
type ScreenShareCaptureMethod = 'display-media' | 'electron-desktop' | 'linux-electron';
|
||||
|
||||
export interface LocalScreenShareState {
|
||||
active: boolean;
|
||||
monitorCaptureSupported: boolean;
|
||||
screenShareSinkName: string;
|
||||
screenShareMonitorSourceName: string;
|
||||
voiceSinkName: string;
|
||||
reason?: string;
|
||||
captureMethod: ScreenShareCaptureMethod | null;
|
||||
includeSystemAudio: boolean;
|
||||
stream: MediaStream | null;
|
||||
suppressRemotePlayback: boolean;
|
||||
forceDefaultRemotePlaybackOutput: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface DesktopSource {
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
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 {
|
||||
/** The active screen-capture stream. */
|
||||
private activeScreenStream: MediaStream | null = null;
|
||||
@@ -127,22 +64,39 @@ export class ScreenShareManager {
|
||||
/** Remote peers that explicitly requested screen-share video. */
|
||||
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. */
|
||||
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(
|
||||
private readonly logger: WebRTCLogger,
|
||||
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.
|
||||
@@ -164,9 +118,10 @@ export class ScreenShareManager {
|
||||
*
|
||||
* On Linux Electron builds, prefers a dedicated PulseAudio/PipeWire routing
|
||||
* path so remote voice playback is kept out of captured system audio.
|
||||
* Otherwise prefers `getDisplayMedia` when system audio is requested so the
|
||||
* browser can filter MeToYou's own playback via `restrictOwnAudio`, then
|
||||
* falls back to Electron desktop capture when needed.
|
||||
* On Windows Electron builds, prefers `getDisplayMedia` with system audio
|
||||
* 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.
|
||||
* @returns The captured screen {@link MediaStream}.
|
||||
@@ -178,6 +133,9 @@ export class ScreenShareManager {
|
||||
...options
|
||||
};
|
||||
const preset = SCREEN_SHARE_QUALITY_PRESETS[shareOptions.quality];
|
||||
const electronDesktopCaptureAvailable = this.desktopElectronScreenShareCapture.isAvailable();
|
||||
|
||||
let captureMethod: ScreenShareCaptureMethod | null = null;
|
||||
|
||||
try {
|
||||
this.logger.info('startScreenShare invoked', shareOptions);
|
||||
@@ -186,42 +144,65 @@ export class ScreenShareManager {
|
||||
this.stopScreenShare();
|
||||
}
|
||||
|
||||
await this.awaitPendingLinuxAudioRoutingReset();
|
||||
await this.linuxElectronScreenShareCapture.awaitPendingReset();
|
||||
|
||||
this.activeScreenStream = null;
|
||||
|
||||
if (shareOptions.includeSystemAudio && this.isLinuxElectronAudioRoutingSupported()) {
|
||||
if (shareOptions.includeSystemAudio && this.linuxElectronScreenShareCapture.isSupported()) {
|
||||
try {
|
||||
this.activeScreenStream = await this.startWithLinuxElectronAudioRouting(shareOptions, preset);
|
||||
this.activeScreenStream = await this.linuxElectronScreenShareCapture.startCapture(shareOptions, preset);
|
||||
captureMethod = 'linux-electron';
|
||||
} catch (error) {
|
||||
this.rethrowIfScreenShareAborted(error);
|
||||
this.logger.warn('Linux Electron audio routing failed; falling back to standard capture', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.activeScreenStream && shareOptions.includeSystemAudio) {
|
||||
try {
|
||||
this.activeScreenStream = await this.startWithDisplayMedia(shareOptions, preset);
|
||||
this.activeScreenStream = await this.browserScreenShareCapture.startCapture(shareOptions, preset);
|
||||
captureMethod = 'display-media';
|
||||
|
||||
if (this.activeScreenStream.getAudioTracks().length === 0) {
|
||||
this.logger.warn('getDisplayMedia did not provide system audio; trying Electron desktop capture');
|
||||
this.activeScreenStream.getTracks().forEach((track) => track.stop());
|
||||
this.activeScreenStream = null;
|
||||
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 = null;
|
||||
captureMethod = null;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.rethrowIfScreenShareAborted(error);
|
||||
this.logger.warn('getDisplayMedia with system audio failed; falling back to Electron desktop capture', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.activeScreenStream && this.getElectronApi()?.getSources) {
|
||||
if (!this.activeScreenStream && electronDesktopCaptureAvailable) {
|
||||
try {
|
||||
this.activeScreenStream = await this.startWithElectronDesktopCapturer(shareOptions, preset);
|
||||
const electronCapture = await this.desktopElectronScreenShareCapture.startCapture(shareOptions, preset);
|
||||
|
||||
this.activeScreenStream = electronCapture.stream;
|
||||
shareOptions.includeSystemAudio = electronCapture.includeSystemAudio;
|
||||
captureMethod = 'electron-desktop';
|
||||
} catch (error) {
|
||||
this.rethrowIfScreenShareAborted(error);
|
||||
this.logger.warn('Electron desktop capture failed; falling back to getDisplayMedia', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.activeScreenStream) {
|
||||
this.activeScreenStream = await this.startWithDisplayMedia(shareOptions, preset);
|
||||
this.activeScreenStream = await this.browserScreenShareCapture.startCapture(shareOptions, preset);
|
||||
captureMethod = 'display-media';
|
||||
}
|
||||
|
||||
this.configureScreenStream(preset);
|
||||
@@ -230,6 +211,7 @@ export class ScreenShareManager {
|
||||
this.attachScreenTracksToPeers(preset);
|
||||
|
||||
this.isScreenActive = true;
|
||||
this.publishLocalScreenShareState(shareOptions.includeSystemAudio, captureMethod);
|
||||
this.callbacks.broadcastCurrentStates();
|
||||
|
||||
const activeScreenStream = this.activeScreenStream;
|
||||
@@ -266,11 +248,12 @@ export class ScreenShareManager {
|
||||
this.activeScreenStream = null;
|
||||
}
|
||||
|
||||
this.scheduleLinuxAudioRoutingReset();
|
||||
this.linuxElectronScreenShareCapture.scheduleReset();
|
||||
|
||||
this.screenAudioStream = null;
|
||||
this.activeScreenPreset = null;
|
||||
this.isScreenActive = false;
|
||||
this.publishLocalScreenShareState(false, null);
|
||||
this.callbacks.broadcastCurrentStates();
|
||||
|
||||
this.callbacks.getActivePeers().forEach((peerData, peerId) => {
|
||||
@@ -347,50 +330,21 @@ export class ScreenShareManager {
|
||||
: null;
|
||||
}
|
||||
|
||||
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
|
||||
private publishLocalScreenShareState(
|
||||
includeSystemAudio: boolean,
|
||||
captureMethod: ScreenShareCaptureMethod | null
|
||||
): 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.');
|
||||
}
|
||||
this.callbacks.updateLocalScreenShareState?.({
|
||||
active: this.isScreenActive,
|
||||
captureMethod: this.isScreenActive ? captureMethod : null,
|
||||
includeSystemAudio: this.isScreenActive ? includeSystemAudio : false,
|
||||
stream: this.isScreenActive ? this.activeScreenStream : null,
|
||||
suppressRemotePlayback: this.isScreenActive
|
||||
&& this.desktopElectronScreenShareCapture.shouldSuppressRemotePlaybackDuringShare(includeSystemAudio),
|
||||
forceDefaultRemotePlaybackOutput: this.isScreenActive
|
||||
&& includeSystemAudio
|
||||
&& captureMethod === 'linux-electron'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -471,6 +425,11 @@ export class ScreenShareManager {
|
||||
}
|
||||
|
||||
peerData.screenVideoSender = videoSender;
|
||||
|
||||
if (typeof videoSender.setStreams === 'function') {
|
||||
videoSender.setStreams(this.activeScreenStream);
|
||||
}
|
||||
|
||||
videoSender.replaceTrack(screenVideoTrack)
|
||||
.then(() => {
|
||||
this.logger.info('screen video replaceTrack ok', { peerId });
|
||||
@@ -501,6 +460,11 @@ export class ScreenShareManager {
|
||||
}
|
||||
|
||||
peerData.screenAudioSender = screenAudioSender;
|
||||
|
||||
if (typeof screenAudioSender.setStreams === 'function') {
|
||||
screenAudioSender.setStreams(this.activeScreenStream);
|
||||
}
|
||||
|
||||
screenAudioSender.replaceTrack(screenAudioTrack)
|
||||
.then(() => this.logger.info('screen audio replaceTrack ok', { peerId }))
|
||||
.catch((error) => this.logger.error('screen audio replaceTrack failed', error));
|
||||
@@ -544,468 +508,17 @@ export class ScreenShareManager {
|
||||
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 isScreenShareSelectionAborted(error: unknown): boolean {
|
||||
return error instanceof Error
|
||||
&& (error.name === 'AbortError' || error.name === 'NotAllowedError');
|
||||
}
|
||||
|
||||
private async startWithElectronDesktopCapturer(
|
||||
options: ScreenShareStartOptions,
|
||||
preset: ScreenShareQualityPreset
|
||||
): Promise<MediaStream> {
|
||||
const electronApi = this.getElectronApi();
|
||||
|
||||
if (!electronApi?.getSources) {
|
||||
throw new Error('Electron desktop capture is unavailable.');
|
||||
}
|
||||
|
||||
const sources = await electronApi.getSources();
|
||||
const screenSource = sources.find((source) => source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME) ?? sources[0];
|
||||
|
||||
if (!screenSource) {
|
||||
throw new Error('No desktop capture sources were available.');
|
||||
}
|
||||
|
||||
const electronConstraints = this.buildElectronDesktopConstraints(screenSource.id, options, 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 await navigator.mediaDevices.getUserMedia(electronConstraints);
|
||||
}
|
||||
|
||||
private isLinuxElectronAudioRoutingSupported(): boolean {
|
||||
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const electronApi = this.getElectronApi();
|
||||
const platformHint = `${navigator.userAgent} ${navigator.platform}`;
|
||||
|
||||
return !!electronApi?.prepareLinuxScreenShareAudioRouting
|
||||
&& !!electronApi?.activateLinuxScreenShareAudioRouting
|
||||
&& !!electronApi?.deactivateLinuxScreenShareAudioRouting
|
||||
&& !!electronApi?.startLinuxScreenShareMonitorCapture
|
||||
&& !!electronApi?.stopLinuxScreenShareMonitorCapture
|
||||
&& !!electronApi?.onLinuxScreenShareMonitorAudioChunk
|
||||
&& !!electronApi?.onLinuxScreenShareMonitorAudioEnded
|
||||
&& /linux/i.test(platformHint);
|
||||
}
|
||||
|
||||
private async startWithLinuxElectronAudioRouting(
|
||||
options: ScreenShareStartOptions,
|
||||
preset: ScreenShareQualityPreset
|
||||
): Promise<MediaStream> {
|
||||
const electronApi = this.getRequiredLinuxElectronApi();
|
||||
const routingInfo = await electronApi.prepareLinuxScreenShareAudioRouting();
|
||||
|
||||
this.assertLinuxAudioRoutingReady(routingInfo, 'Linux Electron audio routing is unavailable.');
|
||||
|
||||
let desktopStream: MediaStream | null = null;
|
||||
|
||||
try {
|
||||
const activation = await electronApi.activateLinuxScreenShareAudioRouting();
|
||||
|
||||
this.assertLinuxAudioRoutingReady(activation, 'Failed to activate Linux Electron audio routing.');
|
||||
|
||||
if (!activation.active) {
|
||||
throw new Error(activation.reason || 'Failed to activate Linux Electron audio routing.');
|
||||
}
|
||||
|
||||
desktopStream = await this.startWithElectronDesktopCapturer({
|
||||
...options,
|
||||
includeSystemAudio: false
|
||||
}, preset);
|
||||
|
||||
const { audioTrack, captureInfo } = await this.startLinuxScreenShareMonitorTrack();
|
||||
const stream = new MediaStream([...desktopStream.getVideoTracks(), audioTrack]);
|
||||
|
||||
desktopStream.getAudioTracks().forEach((track) => track.stop());
|
||||
|
||||
this.linuxElectronAudioRoutingActive = true;
|
||||
this.logger.info('Linux Electron screen-share audio routing enabled', {
|
||||
screenShareMonitorSourceName: captureInfo.sourceName,
|
||||
voiceSinkName: activation.voiceSinkName
|
||||
});
|
||||
|
||||
return stream;
|
||||
} catch (error) {
|
||||
desktopStream?.getTracks().forEach((track) => track.stop());
|
||||
await this.resetLinuxElectronAudioRouting();
|
||||
private rethrowIfScreenShareAborted(error: unknown): void {
|
||||
if (this.isScreenShareSelectionAborted(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 {
|
||||
const screenVideoTrack = this.activeScreenStream?.getVideoTracks()[0];
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ export interface PeerData {
|
||||
screenVideoSender?: RTCRtpSender;
|
||||
/** The RTP sender carrying the screen-share audio track. */
|
||||
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. */
|
||||
|
||||
@@ -107,7 +107,7 @@ export class ChatMessagesComponent {
|
||||
|
||||
handleTypingStarted(): void {
|
||||
try {
|
||||
this.webrtc.sendRawMessage({ type: 'typing' });
|
||||
this.webrtc.sendRawMessage({ type: 'typing', serverId: this.webrtc.currentServerId });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
Injectable,
|
||||
effect,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { WebRTCService } from '../../../../core/services/webrtc.service';
|
||||
import { STORAGE_KEY_USER_VOLUMES } from '../../../../core/constants';
|
||||
|
||||
@@ -14,11 +18,14 @@ export interface PlaybackOptions {
|
||||
*
|
||||
* Chrome/Electron workaround: a muted HTMLAudioElement is attached to
|
||||
* the stream first so that `createMediaStreamSource` actually outputs
|
||||
* audio. The element itself is silent - all audible output comes from
|
||||
* the GainNode -> AudioContext.destination path.
|
||||
* audio. The priming element itself is silent; audible output is routed
|
||||
* through a separate output element fed by
|
||||
* `GainNode -> MediaStreamDestination` so output-device switching stays
|
||||
* reliable during Linux screen sharing.
|
||||
*/
|
||||
interface PeerAudioPipeline {
|
||||
audioElement: HTMLAudioElement;
|
||||
outputElement: HTMLAudioElement;
|
||||
context: AudioContext;
|
||||
sourceNodes: MediaStreamAudioSourceNode[];
|
||||
gainNode: GainNode;
|
||||
@@ -34,11 +41,25 @@ export class VoicePlaybackService {
|
||||
private userVolumes = new Map<string, number>();
|
||||
private userMuted = new Map<string, boolean>();
|
||||
private preferredOutputDeviceId = 'default';
|
||||
private temporaryOutputDeviceId: string | null = null;
|
||||
private masterVolume = 1;
|
||||
private deafened = false;
|
||||
private captureEchoSuppressed = false;
|
||||
|
||||
constructor() {
|
||||
this.loadPersistedVolumes();
|
||||
|
||||
effect(() => {
|
||||
this.captureEchoSuppressed = this.webrtc.isScreenShareRemotePlaybackSuppressed();
|
||||
this.recalcAllGains();
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
this.temporaryOutputDeviceId = this.webrtc.forceDefaultRemotePlaybackOutput()
|
||||
? 'default'
|
||||
: null;
|
||||
void this.applyEffectiveOutputDeviceToAllPipelines();
|
||||
});
|
||||
}
|
||||
|
||||
handleRemoteStream(peerId: string, stream: MediaStream, options: PlaybackOptions): void {
|
||||
@@ -144,11 +165,12 @@ export class VoicePlaybackService {
|
||||
* ↓
|
||||
* muted <audio> element (Chrome workaround - primes the stream)
|
||||
* ↓
|
||||
* MediaStreamSource → GainNode → AudioContext.destination
|
||||
* MediaStreamSource → GainNode → MediaStreamDestination → output <audio>
|
||||
*/
|
||||
private createPipeline(peerId: string, stream: MediaStream): void {
|
||||
// Chromium/Electron needs a muted <audio> element before Web Audio can read the stream.
|
||||
const audioEl = new Audio();
|
||||
const outputEl = new Audio();
|
||||
const audioTracks = stream.getAudioTracks().filter((track) => track.readyState === 'live');
|
||||
|
||||
audioEl.srcObject = stream;
|
||||
@@ -157,12 +179,24 @@ export class VoicePlaybackService {
|
||||
|
||||
const ctx = new AudioContext();
|
||||
const gainNode = ctx.createGain();
|
||||
const mediaDestination = ctx.createMediaStreamDestination();
|
||||
const sourceNodes = audioTracks.map((track) => ctx.createMediaStreamSource(new MediaStream([track])));
|
||||
|
||||
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);
|
||||
|
||||
@@ -184,26 +218,20 @@ export class VoicePlaybackService {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
const anyAudio = pipeline.audioElement as any;
|
||||
// eslint-disable-next-line
|
||||
const anyCtx = pipeline.context as any;
|
||||
const anyAudio = pipeline.outputElement as any;
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
|
||||
if (typeof anyAudio.setSinkId === 'function') {
|
||||
tasks.push(anyAudio.setSinkId(deviceId).catch(() => undefined));
|
||||
}
|
||||
|
||||
if (typeof anyCtx.setSinkId === 'function') {
|
||||
tasks.push(anyCtx.setSinkId(deviceId).catch(() => undefined));
|
||||
}
|
||||
|
||||
if (tasks.length > 0) {
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
}
|
||||
|
||||
private getEffectiveOutputDeviceId(): string {
|
||||
return this.preferredOutputDeviceId;
|
||||
return this.temporaryOutputDeviceId ?? this.preferredOutputDeviceId;
|
||||
}
|
||||
|
||||
private removePipeline(peerId: string): void {
|
||||
@@ -228,6 +256,8 @@ export class VoicePlaybackService {
|
||||
|
||||
pipeline.audioElement.srcObject = null;
|
||||
pipeline.audioElement.remove();
|
||||
pipeline.outputElement.srcObject = null;
|
||||
pipeline.outputElement.remove();
|
||||
|
||||
if (pipeline.context.state !== 'closed') {
|
||||
pipeline.context.close().catch(() => {});
|
||||
@@ -242,7 +272,7 @@ export class VoicePlaybackService {
|
||||
if (!pipeline)
|
||||
return;
|
||||
|
||||
if (this.deafened || this.isUserMuted(peerId)) {
|
||||
if (this.deafened || this.captureEchoSuppressed || this.isUserMuted(peerId)) {
|
||||
pipeline.gainNode.gain.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,65 @@
|
||||
{{ autoScroll() ? 'Pause auto-scroll' : 'Resume auto-scroll' }}
|
||||
</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
|
||||
type="button"
|
||||
(click)="clear()"
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import {
|
||||
Component,
|
||||
HostListener,
|
||||
input,
|
||||
output
|
||||
output,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideDownload,
|
||||
lucideFilter,
|
||||
lucidePause,
|
||||
lucidePlay,
|
||||
@@ -15,6 +18,7 @@ import {
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
type DebugLogLevel = 'event' | 'info' | 'warn' | 'error' | 'debug';
|
||||
type DebugExportFormat = 'csv' | 'txt';
|
||||
|
||||
interface DebugNetworkSummary {
|
||||
clientCount: number;
|
||||
@@ -34,6 +38,7 @@ interface DebugNetworkSummary {
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideDownload,
|
||||
lucideFilter,
|
||||
lucidePause,
|
||||
lucidePlay,
|
||||
@@ -64,6 +69,10 @@ export class DebugConsoleToolbarComponent {
|
||||
readonly autoScrollToggled = output<undefined>();
|
||||
readonly clearRequested = output<undefined>();
|
||||
readonly closeRequested = output<undefined>();
|
||||
readonly exportLogsRequested = output<DebugExportFormat>();
|
||||
readonly exportNetworkRequested = output<DebugExportFormat>();
|
||||
|
||||
readonly exportMenuOpen = signal(false);
|
||||
|
||||
readonly levels: DebugLogLevel[] = [
|
||||
'event',
|
||||
@@ -111,6 +120,35 @@ export class DebugConsoleToolbarComponent {
|
||||
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 {
|
||||
return this.detached() ? 'Dock' : 'Detach';
|
||||
}
|
||||
|
||||
@@ -102,10 +102,11 @@
|
||||
[style.left.px]="detached() ? panelLeft() : null"
|
||||
[style.top.px]="detached() ? panelTop() : null"
|
||||
>
|
||||
<!-- Left resize bar -->
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<span
|
||||
@@ -113,10 +114,23 @@
|
||||
></span>
|
||||
</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
|
||||
type="button"
|
||||
class="group relative h-3 w-full cursor-row-resize bg-transparent"
|
||||
(mousedown)="startResize($event)"
|
||||
(mousedown)="startTopResize($event)"
|
||||
aria-label="Resize debug console"
|
||||
>
|
||||
<span
|
||||
@@ -154,6 +168,8 @@
|
||||
(autoScrollToggled)="toggleAutoScroll()"
|
||||
(clearRequested)="clearLogs()"
|
||||
(closeRequested)="closeConsole()"
|
||||
(exportLogsRequested)="exportLogs($event)"
|
||||
(exportNetworkRequested)="exportNetwork($event)"
|
||||
/>
|
||||
|
||||
@if (activeTab() === 'logs') {
|
||||
@@ -168,6 +184,48 @@
|
||||
[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>
|
||||
</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 { DebugConsoleNetworkMapComponent } from './debug-console-network-map/debug-console-network-map.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>;
|
||||
|
||||
@@ -44,6 +47,9 @@ type DebugConsoleLauncherVariant = 'floating' | 'inline' | 'compact';
|
||||
})
|
||||
export class DebugConsoleComponent {
|
||||
readonly debugging = inject(DebuggingService);
|
||||
readonly resizeService = inject(DebugConsoleResizeService);
|
||||
readonly exportService = inject(DebugConsoleExportService);
|
||||
readonly envService = inject(DebugConsoleEnvironmentService);
|
||||
readonly entries = this.debugging.entries;
|
||||
readonly isOpen = this.debugging.isConsoleOpen;
|
||||
readonly networkSnapshot = this.debugging.networkSnapshot;
|
||||
@@ -56,10 +62,10 @@ export class DebugConsoleComponent {
|
||||
readonly searchTerm = signal('');
|
||||
readonly selectedSource = signal('all');
|
||||
readonly autoScroll = signal(true);
|
||||
readonly panelHeight = signal(360);
|
||||
readonly panelWidth = signal(832);
|
||||
readonly panelLeft = signal(0);
|
||||
readonly panelTop = signal(0);
|
||||
readonly panelHeight = this.resizeService.panelHeight;
|
||||
readonly panelWidth = this.resizeService.panelWidth;
|
||||
readonly panelLeft = this.resizeService.panelLeft;
|
||||
readonly panelTop = this.resizeService.panelTop;
|
||||
readonly levelState = signal<DebugLevelState>({
|
||||
event: true,
|
||||
info: true,
|
||||
@@ -123,18 +129,8 @@ export class DebugConsoleComponent {
|
||||
readonly hasErrors = computed(() => this.levelCounts().error > 0);
|
||||
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() {
|
||||
this.syncPanelBounds();
|
||||
this.resizeService.syncBounds(this.detached());
|
||||
|
||||
effect(() => {
|
||||
const selectedSource = this.selectedSource();
|
||||
@@ -147,32 +143,17 @@ export class DebugConsoleComponent {
|
||||
|
||||
@HostListener('window:mousemove', ['$event'])
|
||||
onResizeMove(event: MouseEvent): void {
|
||||
if (this.dragging) {
|
||||
this.updateDetachedPosition(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.resizingWidth) {
|
||||
this.updatePanelWidth(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.resizingHeight)
|
||||
return;
|
||||
|
||||
this.updatePanelHeight(event);
|
||||
this.resizeService.onMouseMove(event, this.detached());
|
||||
}
|
||||
|
||||
@HostListener('window:mouseup')
|
||||
onResizeEnd(): void {
|
||||
this.dragging = false;
|
||||
this.resizingHeight = false;
|
||||
this.resizingWidth = false;
|
||||
this.resizeService.onMouseUp();
|
||||
}
|
||||
|
||||
@HostListener('window:resize')
|
||||
onWindowResize(): void {
|
||||
this.syncPanelBounds();
|
||||
this.resizeService.syncBounds(this.detached());
|
||||
}
|
||||
|
||||
toggleConsole(): void {
|
||||
@@ -195,14 +176,38 @@ export class DebugConsoleComponent {
|
||||
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 {
|
||||
const nextDetached = !this.detached();
|
||||
|
||||
this.detached.set(nextDetached);
|
||||
this.syncPanelBounds();
|
||||
this.resizeService.syncBounds(nextDetached);
|
||||
|
||||
if (nextDetached)
|
||||
this.initializeDetachedPosition();
|
||||
this.resizeService.initializeDetachedPosition();
|
||||
}
|
||||
|
||||
toggleLevel(level: DebugLogLevel): void {
|
||||
@@ -220,35 +225,31 @@ export class DebugConsoleComponent {
|
||||
this.debugging.clear();
|
||||
}
|
||||
|
||||
startResize(event: MouseEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.resizingHeight = true;
|
||||
this.resizeOriginY = event.clientY;
|
||||
this.resizeOriginHeight = this.panelHeight();
|
||||
this.panelOriginTop = this.panelTop();
|
||||
startTopResize(event: MouseEvent): void {
|
||||
this.resizeService.startTopResize(event);
|
||||
}
|
||||
|
||||
startWidthResize(event: MouseEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.resizingWidth = true;
|
||||
this.resizeOriginX = event.clientX;
|
||||
this.resizeOriginWidth = this.panelWidth();
|
||||
this.panelOriginLeft = this.panelLeft();
|
||||
startBottomResize(event: MouseEvent): void {
|
||||
this.resizeService.startBottomResize(event);
|
||||
}
|
||||
|
||||
startLeftResize(event: MouseEvent): void {
|
||||
this.resizeService.startLeftResize(event);
|
||||
}
|
||||
|
||||
startRightResize(event: MouseEvent): void {
|
||||
this.resizeService.startRightResize(event);
|
||||
}
|
||||
|
||||
startCornerResize(event: MouseEvent): void {
|
||||
this.resizeService.startCornerResize(event);
|
||||
}
|
||||
|
||||
startDrag(event: MouseEvent): void {
|
||||
if (!this.detached())
|
||||
return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.dragging = true;
|
||||
this.resizeOriginX = event.clientX;
|
||||
this.resizeOriginY = event.clientY;
|
||||
this.panelOriginLeft = this.panelLeft();
|
||||
this.panelOriginTop = this.panelTop();
|
||||
this.resizeService.startDrag(event);
|
||||
}
|
||||
|
||||
formatBadgeCount(count: number): string {
|
||||
@@ -257,92 +258,4 @@ export class DebugConsoleComponent {
|
||||
|
||||
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,234 @@
|
||||
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;
|
||||
}
|
||||
|
||||
interface DebugConsoleElectronApi {
|
||||
linuxDisplayServer?: 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';
|
||||
|
||||
const electronDisplayServer = this.readElectronDisplayServer();
|
||||
|
||||
if (electronDisplayServer)
|
||||
return electronDisplayServer;
|
||||
|
||||
try {
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
|
||||
if (ua.includes('wayland'))
|
||||
return 'Wayland';
|
||||
|
||||
const isOzone = ua.includes('ozone');
|
||||
|
||||
if (isOzone)
|
||||
return 'Ozone (Wayland likely)';
|
||||
|
||||
if (ua.includes('x11'))
|
||||
return 'X11';
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
return this.detectDisplayServerFromEnv();
|
||||
}
|
||||
|
||||
private readElectronDisplayServer(): string | null {
|
||||
try {
|
||||
const displayServer = this.getElectronApi()?.linuxDisplayServer;
|
||||
|
||||
return typeof displayServer === 'string' && displayServer.trim().length > 0
|
||||
? displayServer
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private detectDisplayServerFromEnv(): string {
|
||||
try {
|
||||
// Electron may expose env vars
|
||||
const api = this.getElectronApi();
|
||||
|
||||
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(): DebugConsoleElectronApi | null {
|
||||
try {
|
||||
const win = window as Window &
|
||||
{ electronAPI?: DebugConsoleElectronApi };
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
@if (request(); as pickerRequest) {
|
||||
<div
|
||||
class="fixed inset-0 z-[110] bg-black/70 backdrop-blur-sm"
|
||||
(click)="cancel()"
|
||||
(keydown.enter)="cancel()"
|
||||
(keydown.space)="cancel()"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Close source picker"
|
||||
></div>
|
||||
|
||||
<div class="fixed inset-0 z-[111] flex items-center justify-center p-4 pointer-events-none">
|
||||
<section
|
||||
class="pointer-events-auto w-full max-w-6xl rounded-2xl border border-border bg-card shadow-2xl"
|
||||
(click)="$event.stopPropagation()"
|
||||
(keydown.enter)="$event.stopPropagation()"
|
||||
(keydown.space)="$event.stopPropagation()"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="screen-share-source-picker-title"
|
||||
aria-describedby="screen-share-source-picker-description"
|
||||
tabindex="-1"
|
||||
>
|
||||
<header class="border-b border-border p-5">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h2 id="screen-share-source-picker-title" class="text-lg font-semibold text-foreground">
|
||||
Choose what to share
|
||||
</h2>
|
||||
<p id="screen-share-source-picker-description" class="mt-1 text-sm text-muted-foreground">
|
||||
Select a screen or window to start sharing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label
|
||||
class="flex items-center justify-between gap-3 rounded-xl border border-border bg-secondary/30 px-4 py-3 lg:min-w-80"
|
||||
for="screen-share-include-system-audio-toggle"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Include system audio</p>
|
||||
<p class="text-xs text-muted-foreground">Share desktop sound with viewers.</p>
|
||||
</div>
|
||||
<span class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
id="screen-share-include-system-audio-toggle"
|
||||
type="checkbox"
|
||||
class="sr-only peer"
|
||||
[checked]="includeSystemAudio()"
|
||||
(change)="onIncludeSystemAudioChange($event)"
|
||||
/>
|
||||
<span
|
||||
class="relative block h-5 w-10 rounded-full bg-secondary transition-colors peer-checked:bg-primary after:absolute after:left-[2px] after:top-0.5 after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-transform after:content-[''] peer-checked:after:translate-x-full"
|
||||
></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2" role="tablist" aria-label="Share source type">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
role="tab"
|
||||
[attr.aria-selected]="activeTab() === 'screen'"
|
||||
[disabled]="getTabCount('screen') === 0"
|
||||
[class.border-primary]="activeTab() === 'screen'"
|
||||
[class.bg-primary/10]="activeTab() === 'screen'"
|
||||
[class.text-primary]="activeTab() === 'screen'"
|
||||
[class.border-border]="activeTab() !== 'screen'"
|
||||
[class.bg-secondary/30]="activeTab() !== 'screen'"
|
||||
[class.text-foreground]="activeTab() !== 'screen'"
|
||||
(click)="setActiveTab('screen')"
|
||||
>
|
||||
Entire screen
|
||||
<span class="rounded-full bg-black/10 px-2 py-0.5 text-xs text-current">
|
||||
{{ getTabCount('screen') }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
role="tab"
|
||||
[attr.aria-selected]="activeTab() === 'window'"
|
||||
[disabled]="getTabCount('window') === 0"
|
||||
[class.border-primary]="activeTab() === 'window'"
|
||||
[class.bg-primary/10]="activeTab() === 'window'"
|
||||
[class.text-primary]="activeTab() === 'window'"
|
||||
[class.border-border]="activeTab() !== 'window'"
|
||||
[class.bg-secondary/30]="activeTab() !== 'window'"
|
||||
[class.text-foreground]="activeTab() !== 'window'"
|
||||
(click)="setActiveTab('window')"
|
||||
>
|
||||
Windows
|
||||
<span class="rounded-full bg-black/10 px-2 py-0.5 text-xs text-current">
|
||||
{{ getTabCount('window') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (includeSystemAudio()) {
|
||||
<p class="mt-3 rounded-lg bg-primary/10 px-3 py-2 text-xs text-primary">
|
||||
Computer audio will be shared. MeToYou audio is filtered when supported, and your microphone stays on its normal voice track.
|
||||
</p>
|
||||
}
|
||||
</header>
|
||||
|
||||
<div class="screen-share-source-picker__body">
|
||||
@if (filteredSources().length > 0) {
|
||||
<div
|
||||
class="screen-share-source-picker__grid"
|
||||
[class.screen-share-source-picker__grid--screen]="activeTab() === 'screen'"
|
||||
[class.screen-share-source-picker__grid--window]="activeTab() === 'window'"
|
||||
>
|
||||
@for (source of filteredSources(); track trackSource($index, source)) {
|
||||
<button
|
||||
#sourceButton
|
||||
type="button"
|
||||
class="rounded-xl border px-4 py-4 text-left transition-colors screen-share-source-picker__source"
|
||||
[attr.aria-pressed]="selectedSourceId() === source.id"
|
||||
[attr.data-source-id]="source.id"
|
||||
[class.border-primary]="selectedSourceId() === source.id"
|
||||
[class.bg-primary/10]="selectedSourceId() === source.id"
|
||||
[class.text-primary]="selectedSourceId() === source.id"
|
||||
[class.border-border]="selectedSourceId() !== source.id"
|
||||
[class.bg-secondary/30]="selectedSourceId() !== source.id"
|
||||
[class.text-foreground]="selectedSourceId() !== source.id"
|
||||
(click)="selectSource(source.id)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="screen-share-source-picker__preview">
|
||||
<img [ngSrc]="source.thumbnail" [alt]="source.name" fill />
|
||||
</span>
|
||||
|
||||
<p class="mt-3 truncate font-medium">{{ source.name }}</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ source.kind === 'screen' ? 'Entire screen' : 'Window' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="mt-0.5 inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full border text-[10px]"
|
||||
[class.border-primary]="selectedSourceId() === source.id"
|
||||
[class.bg-primary]="selectedSourceId() === source.id"
|
||||
[class.text-primary-foreground]="selectedSourceId() === source.id"
|
||||
[class.border-border]="selectedSourceId() !== source.id"
|
||||
>
|
||||
@if (selectedSourceId() === source.id) {
|
||||
✓
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="flex min-h-52 items-center justify-center px-5 py-8 text-center">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
No {{ activeTab() === 'screen' ? 'screens' : 'windows' }} available
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ activeTab() === 'screen'
|
||||
? 'No displays were reported by Electron right now.'
|
||||
: 'Restore the window you want to share and try again.' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<footer class="flex items-center justify-end gap-2 border-t border-border p-4">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-secondary px-4 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
|
||||
(click)="cancel()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
[disabled]="!selectedSourceId()"
|
||||
(click)="confirmSelection()"
|
||||
>
|
||||
Start sharing
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.screen-share-source-picker__body {
|
||||
max-height: min(36rem, calc(100vh - 15rem));
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.screen-share-source-picker__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
align-content: start;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.screen-share-source-picker__grid--screen {
|
||||
grid-template-columns: repeat(auto-fill, minmax(12.5rem, 15rem));
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.screen-share-source-picker__grid--window {
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
}
|
||||
|
||||
.screen-share-source-picker__source {
|
||||
cursor: pointer;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.screen-share-source-picker__source:focus-visible {
|
||||
outline: 2px solid hsl(var(--ring));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.screen-share-source-picker__preview {
|
||||
display: block;
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 10;
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.875rem;
|
||||
background: hsl(var(--secondary) / 0.45);
|
||||
}
|
||||
|
||||
.screen-share-source-picker__preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
background: hsl(var(--secondary) / 0.3);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.screen-share-source-picker__body {
|
||||
max-height: calc(100vh - 22rem);
|
||||
}
|
||||
|
||||
.screen-share-source-picker__grid {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
viewChildren
|
||||
} from '@angular/core';
|
||||
import {
|
||||
ScreenShareSourceKind,
|
||||
ScreenShareSourceOption,
|
||||
ScreenShareSourcePickerService
|
||||
} from '../../../core/services/screen-share-source-picker.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-screen-share-source-picker',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgOptimizedImage],
|
||||
templateUrl: './screen-share-source-picker.component.html',
|
||||
styleUrl: './screen-share-source-picker.component.scss',
|
||||
host: {
|
||||
style: 'display: contents;'
|
||||
}
|
||||
})
|
||||
export class ScreenShareSourcePickerComponent {
|
||||
readonly picker = inject(ScreenShareSourcePickerService);
|
||||
readonly request = this.picker.request;
|
||||
readonly sources = computed(() => this.request()?.sources ?? []);
|
||||
readonly screenSources = computed(() => this.sources().filter((source) => source.kind === 'screen'));
|
||||
readonly windowSources = computed(() => this.sources().filter((source) => source.kind === 'window'));
|
||||
readonly filteredSources = computed(() => {
|
||||
return this.activeTab() === 'screen'
|
||||
? this.screenSources()
|
||||
: this.windowSources();
|
||||
});
|
||||
readonly hasOpenRequest = computed(() => !!this.request());
|
||||
readonly activeTab = signal<ScreenShareSourceKind>('screen');
|
||||
readonly includeSystemAudio = signal(false);
|
||||
readonly selectedSourceId = signal<string | null>(null);
|
||||
|
||||
private readonly sourceButtons = viewChildren<ElementRef<HTMLButtonElement>>('sourceButton');
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const request = this.request();
|
||||
const defaultTab: ScreenShareSourceKind = request?.sources.some((source) => source.kind === 'screen')
|
||||
? 'screen'
|
||||
: 'window';
|
||||
|
||||
this.activeTab.set(defaultTab);
|
||||
this.includeSystemAudio.set(request?.includeSystemAudio ?? false);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const sources = this.filteredSources();
|
||||
const selectedSourceId = this.selectedSourceId();
|
||||
|
||||
if (!sources.some((source) => source.id === selectedSourceId)) {
|
||||
this.selectedSourceId.set(sources[0]?.id ?? null);
|
||||
}
|
||||
|
||||
if (sources.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
const activeSourceId = this.selectedSourceId();
|
||||
const targetButton = this.sourceButtons().find(
|
||||
(button) => button.nativeElement.dataset['sourceId'] === activeSourceId
|
||||
) ?? this.sourceButtons()[0];
|
||||
|
||||
targetButton?.nativeElement.focus();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
if (this.hasOpenRequest()) {
|
||||
this.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
trackSource(_index: number, source: ScreenShareSourceOption): string {
|
||||
return source.id;
|
||||
}
|
||||
|
||||
setActiveTab(tab: ScreenShareSourceKind): void {
|
||||
if (!this.getTabSources(tab).length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeTab.set(tab);
|
||||
}
|
||||
|
||||
getTabCount(tab: ScreenShareSourceKind): number {
|
||||
return this.getTabSources(tab).length;
|
||||
}
|
||||
|
||||
selectSource(sourceId: string): void {
|
||||
this.selectedSourceId.set(sourceId);
|
||||
}
|
||||
|
||||
onIncludeSystemAudioChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.includeSystemAudio.set(!!input.checked);
|
||||
}
|
||||
|
||||
confirmSelection(): void {
|
||||
const sourceId = this.selectedSourceId();
|
||||
|
||||
if (!sourceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.picker.confirm(sourceId, this.includeSystemAudio());
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.picker.cancel();
|
||||
}
|
||||
|
||||
private getTabSources(tab: ScreenShareSourceKind): ScreenShareSourceOption[] {
|
||||
return tab === 'screen'
|
||||
? this.screenSources()
|
||||
: this.windowSources();
|
||||
}
|
||||
}
|
||||
@@ -9,4 +9,5 @@ export { ChatAudioPlayerComponent } from './components/chat-audio-player/chat-au
|
||||
export { ChatVideoPlayerComponent } from './components/chat-video-player/chat-video-player.component';
|
||||
export { DebugConsoleComponent } from './components/debug-console/debug-console.component';
|
||||
export { ScreenShareQualityDialogComponent } from './components/screen-share-quality-dialog/screen-share-quality-dialog.component';
|
||||
export { ScreenShareSourcePickerComponent } from './components/screen-share-source-picker/screen-share-source-picker.component';
|
||||
export { UserVolumeMenuComponent } from './components/user-volume-menu/user-volume-menu.component';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const environment = {
|
||||
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
|
||||
70
tools/launch-electron.js
Normal file
@@ -0,0 +1,70 @@
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
function isWaylandSession(env) {
|
||||
const sessionType = String(env.XDG_SESSION_TYPE || '').trim().toLowerCase();
|
||||
|
||||
if (sessionType === 'wayland') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return String(env.WAYLAND_DISPLAY || '').trim().length > 0;
|
||||
}
|
||||
|
||||
function hasSwitch(args, switchName) {
|
||||
const normalizedSwitch = `--${switchName}`;
|
||||
|
||||
return args.some((arg) => arg === normalizedSwitch || arg.startsWith(`${normalizedSwitch}=`));
|
||||
}
|
||||
|
||||
function resolveElectronBinary() {
|
||||
const electronModule = require('electron');
|
||||
|
||||
if (typeof electronModule === 'string') {
|
||||
return electronModule;
|
||||
}
|
||||
|
||||
if (electronModule && typeof electronModule.default === 'string') {
|
||||
return electronModule.default;
|
||||
}
|
||||
|
||||
throw new Error('Could not resolve the Electron executable.');
|
||||
}
|
||||
|
||||
function buildElectronArgs(argv) {
|
||||
const args = [...argv];
|
||||
|
||||
if (
|
||||
process.platform === 'linux'
|
||||
&& isWaylandSession(process.env)
|
||||
&& !hasSwitch(args, 'ozone-platform')
|
||||
) {
|
||||
args.push('--ozone-platform=wayland');
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const electronBinary = resolveElectronBinary();
|
||||
const args = buildElectronArgs(process.argv.slice(2));
|
||||
const child = spawn(electronBinary, args, {
|
||||
env: process.env,
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
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
|
||||
}
|
||||
}
|
||||
15850
website/package-lock.json
generated
Normal file
51
website/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"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",
|
||||
"@ngx-translate/core": "^17.0.0",
|
||||
"@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 |
353
website/public/i18n/en.json
Normal file
@@ -0,0 +1,353 @@
|
||||
{
|
||||
"common": {
|
||||
"brand": "Toju",
|
||||
"os": {
|
||||
"windows": "Windows",
|
||||
"macos": "macOS",
|
||||
"linux": "Linux",
|
||||
"linuxDebian": "Linux (deb)",
|
||||
"archive": "Archive",
|
||||
"web": "Web",
|
||||
"other": "Other"
|
||||
},
|
||||
"actions": {
|
||||
"downloadBrand": "Download Toju",
|
||||
"downloadFor": "Download for {{os}}",
|
||||
"openInBrowser": "Open in Browser",
|
||||
"tryInBrowser": "Try in Browser",
|
||||
"useWebVersion": "Use Web Version",
|
||||
"openWebVersion": "Open web version",
|
||||
"goToDownloads": "Go to downloads",
|
||||
"learnHowItWorks": "Learn how it works",
|
||||
"viewSourceCode": "View source code",
|
||||
"buyUsCoffee": "Buy us a coffee"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"header": {
|
||||
"homeAriaLabel": "Toju home",
|
||||
"beta": "Beta",
|
||||
"navigation": {
|
||||
"home": "Home",
|
||||
"whatIsToju": "What is Toju?",
|
||||
"downloads": "Downloads",
|
||||
"philosophy": "Our Philosophy"
|
||||
},
|
||||
"supportUs": "Support Us",
|
||||
"useWebVersion": "Use Web Version",
|
||||
"toggleMenu": "Toggle menu"
|
||||
},
|
||||
"footer": {
|
||||
"description": "Free, open-source, peer-to-peer communication. Built by people who believe privacy is a right, not a premium feature.",
|
||||
"sections": {
|
||||
"product": "Product",
|
||||
"community": "Community",
|
||||
"values": "Values"
|
||||
},
|
||||
"links": {
|
||||
"downloads": "Downloads",
|
||||
"webVersion": "Web Version",
|
||||
"whatIsToju": "What is Toju?",
|
||||
"imageGallery": "Image Gallery",
|
||||
"sourceCode": "Source Code",
|
||||
"github": "GitHub",
|
||||
"supportUs": "Support Us",
|
||||
"ourPhilosophy": "Our Philosophy"
|
||||
},
|
||||
"values": {
|
||||
"freeForever": "100% Free Forever",
|
||||
"openSource": "Open Source"
|
||||
},
|
||||
"copyright": "© {{year}} Myxelium. Toju is open-source software.",
|
||||
"viewSourceOnGitea": "View source code on Gitea",
|
||||
"viewProjectOnGitHub": "View the project on GitHub"
|
||||
},
|
||||
"adSlot": {
|
||||
"label": "Advertisement"
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"home": {
|
||||
"seo": {
|
||||
"title": "Free Peer-to-Peer Voice, Video & Chat",
|
||||
"description": "Toju is a free, open-source, peer-to-peer communication app. Crystal-clear voice calls, unlimited screen sharing, no file size limits, complete privacy."
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Currently in Beta - Free & Open Source",
|
||||
"titleLine1": "Talk freely.",
|
||||
"titleLine2": "Own your voice.",
|
||||
"description": "Crystal-clear voice calls, unlimited screen sharing, and file transfers with no size limits. Peer-to-peer. Private. Completely free.",
|
||||
"version": "Version {{version}}",
|
||||
"allPlatforms": "All platforms"
|
||||
},
|
||||
"features": {
|
||||
"titleLine1": "Everything you need,",
|
||||
"titleLine2": "nothing you don't.",
|
||||
"description": "No bloat. No paywalls. Just the tools to connect with the people who matter.",
|
||||
"items": {
|
||||
"voiceCalls": {
|
||||
"title": "HD Voice Calls",
|
||||
"description": "Crystal-clear audio with built-in noise reduction. Hear every word, not the background. No quality compromises, ever."
|
||||
},
|
||||
"screenSharing": {
|
||||
"title": "Screen Sharing",
|
||||
"description": "Share your screen at full resolution. No time limits, no quality downgrades. Perfect for pair programming, presentations, or showing your epic gameplay."
|
||||
},
|
||||
"fileSharing": {
|
||||
"title": "Unlimited File Sharing",
|
||||
"description": "Send files of any size directly to your friends. No upload limits, no compression. Your files go straight from you to them."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "True Privacy",
|
||||
"description": "Peer-to-peer means your data goes directly between you and your friends. No servers storing your conversations. Your business is your business."
|
||||
},
|
||||
"openSource": {
|
||||
"title": "Open Source",
|
||||
"description": "Every line of code is public. Audit it, modify it, host your own signal server. Full transparency - nothing is hidden."
|
||||
},
|
||||
"free": {
|
||||
"title": "Completely Free",
|
||||
"description": "No premium tiers. No paywalls. No \"starter plans\". Every feature is available to everyone, always. Made with love, not profit margins."
|
||||
}
|
||||
}
|
||||
},
|
||||
"gaming": {
|
||||
"badge": "Built for Gamers",
|
||||
"titleLine1": "Your perfect",
|
||||
"titleLine2": "gaming companion.",
|
||||
"description": "Ultra-low latency voice chat that doesn't eat your bandwidth. Share your screen without frame drops. Send clips and files instantly. All while keeping your CPU free for what matters - winning.",
|
||||
"bullets": {
|
||||
"lowLatency": "Low-latency peer-to-peer voice - no relay servers in the way",
|
||||
"noiseSuppression": "AI-powered noise suppression - keyboard clatter stays out",
|
||||
"screenShare": "Full-resolution screen sharing at high FPS",
|
||||
"fileTransfers": "Send replays and screenshots with no file size limit"
|
||||
},
|
||||
"imageAlt": "Toju gaming screen sharing preview",
|
||||
"caption": "Game on. No limits."
|
||||
},
|
||||
"selfHostable": {
|
||||
"badge": "Self-Hostable",
|
||||
"titleLine1": "Your infrastructure,",
|
||||
"titleLine2": "your rules.",
|
||||
"description": "Toju uses a lightweight coordination server to help peers find each other - that's it. Your actual conversations never touch a server. Want even more control? Run your own coordination server in minutes. Full independence, zero compromises."
|
||||
},
|
||||
"cta": {
|
||||
"title": "Ready to take back your conversations?",
|
||||
"description": "Join thousands choosing privacy, freedom, and real connection."
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"seo": {
|
||||
"title": "Download Toju",
|
||||
"description": "Download Toju for Windows, Linux, or use the web version. Free peer-to-peer voice chat, screen sharing, and file transfers."
|
||||
},
|
||||
"hero": {
|
||||
"titlePrefix": "Download",
|
||||
"description": "Available for Windows, Linux, and in your browser. Always free, always the full experience."
|
||||
},
|
||||
"recommended": {
|
||||
"badge": "Recommended for you",
|
||||
"title": "Toju for {{os}}",
|
||||
"version": "Version {{version}}",
|
||||
"webVersionPrefix": "Or",
|
||||
"webVersionLink": "use the web version",
|
||||
"webVersionSuffix": "- no download required."
|
||||
},
|
||||
"allPlatforms": {
|
||||
"title": "All platforms",
|
||||
"assetIconAlt": "{{os}} icon"
|
||||
},
|
||||
"previousReleases": {
|
||||
"title": "Previous Releases",
|
||||
"fileCount": "{{count}} files"
|
||||
},
|
||||
"loading": "Fetching releases...",
|
||||
"rss": {
|
||||
"prefix": "Stay updated with our",
|
||||
"link": "RSS feed"
|
||||
}
|
||||
},
|
||||
"gallery": {
|
||||
"seo": {
|
||||
"title": "Toju Image Gallery",
|
||||
"description": "Browse screenshots of Toju and explore the interface for chat, file sharing, voice, and screen sharing."
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Image Gallery",
|
||||
"titlePrefix": "A closer look at",
|
||||
"description": "Explore screenshots of the app experience, from voice chat and media sharing to servers, rooms, and full-screen collaboration."
|
||||
},
|
||||
"featured": {
|
||||
"imageAlt": "Toju main application screenshot",
|
||||
"label": "Featured",
|
||||
"title": "The full Toju workspace",
|
||||
"description": "See the main interface where rooms, messages, presence, and media all come together in one focused layout."
|
||||
},
|
||||
"items": {
|
||||
"mainChatView": {
|
||||
"title": "Main chat view",
|
||||
"description": "The core Toju experience with channels, messages, and direct communication tools."
|
||||
},
|
||||
"gamingScreenShare": {
|
||||
"title": "Gaming screen share",
|
||||
"description": "Share gameplay, guides, and live moments with smooth full-resolution screen sharing."
|
||||
},
|
||||
"serverOverview": {
|
||||
"title": "Server overview",
|
||||
"description": "Navigate servers and rooms with a layout designed for clarity and speed."
|
||||
},
|
||||
"musicAndVoice": {
|
||||
"title": "Music and voice",
|
||||
"description": "Stay in sync with voice and media features in a focused, low-friction interface."
|
||||
},
|
||||
"videoSharing": {
|
||||
"title": "Video sharing",
|
||||
"description": "Preview and share visual content directly with your friends and communities."
|
||||
},
|
||||
"fileTransfers": {
|
||||
"title": "File transfers",
|
||||
"description": "Move files quickly without artificial size limits or unnecessary hoops."
|
||||
},
|
||||
"richMediaChat": {
|
||||
"title": "Rich media chat",
|
||||
"description": "Conversations stay lively with visual media support built right in."
|
||||
}
|
||||
},
|
||||
"cta": {
|
||||
"title": "Want to see it in action?",
|
||||
"description": "Download Toju or jump into the browser experience and explore the interface yourself."
|
||||
}
|
||||
},
|
||||
"philosophy": {
|
||||
"seo": {
|
||||
"title": "Our Philosophy - Why We Build Toju",
|
||||
"description": "Toju exists because privacy is a right, not a premium feature. No paywalls, no data harvesting, no predatory pricing. Learn why we build free, open-source communication tools."
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Our Manifesto",
|
||||
"titlePrefix": "Why we",
|
||||
"titleHighlight": "build",
|
||||
"description": "A letter from the people behind the project."
|
||||
},
|
||||
"sections": {
|
||||
"ownership": {
|
||||
"title": "We Lost Something Important",
|
||||
"paragraph1": "Over the past two decades, something fundamental shifted. Our conversations, our memories, our connections - they stopped belonging to us. They live on servers we don't control, inside apps that treat our personal lives as data to be harvested, analyzed, and sold to the highest bidder.",
|
||||
"paragraph2": "We gave up ownership of our digital lives so gradually that most of us didn't even notice. A \"free\" app here, a convenient service there - each one taking a little more of our privacy in exchange for convenience. Toju exists because we believe it's time to take it back."
|
||||
},
|
||||
"paywalls": {
|
||||
"title": "No Paywalls. No Premium Tiers. Ever.",
|
||||
"paragraph1": "You know the playbook: launch a free product, build a user base, then start locking features behind subscription tiers. Can't share your screen at more than 720p unless you upgrade. File size limited to 8 MB. Want noise suppression? That's a premium feature now.",
|
||||
"paragraph2": "We refuse to play that game. <strong class=\"text-foreground\">Every feature in Toju is available to every user, always.</strong> There is no \"Toju Nitro,\" no \"Pro plan,\" no artificial limitations designed to push you toward your wallet. Communication is a human need, not a luxury - and the tools for it should reflect that."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Privacy Is a Right, Not a Feature",
|
||||
"paragraph1": "Most communication platforms collect everything: who you talk to, when, for how long, what you share. They build profiles of your social graph, your habits, your interests. Even services that claim to care about privacy still store metadata on their servers.",
|
||||
"paragraph2": "Toju is architecturally different. Your data goes directly from your device to your friend's device. We don't have your messages. We don't have your files. We don't have your call history. Not because we promised not to look - but because the data never touches our infrastructure. We built the technology so that <strong class=\"text-foreground\">privacy isn't something we offer; it's something we literally cannot violate.</strong>"
|
||||
},
|
||||
"heart": {
|
||||
"title": "Built from the Heart",
|
||||
"paragraph1": "Toju wasn't born in a boardroom with revenue projections. It was born from frustration - frustration with being the product, with watching friends get locked out of features they used to have for free, with the growing feeling that the tools we depend on daily don't actually serve our interests.",
|
||||
"paragraph2": "We build Toju because we genuinely want to make the world a little better. The internet was supposed to connect people freely, and somewhere along the way, that mission got hijacked by business models that exploit the very connections they facilitate. <strong class=\"text-foreground\">Toju is our small act of reclaiming that original promise.</strong>"
|
||||
},
|
||||
"openSource": {
|
||||
"title": "Transparent by Default",
|
||||
"paragraph1": "Every line of Toju's code is publicly available. You can read it, audit it, contribute to it, or fork it and build your own version. This isn't a marketing decision - it's an accountability decision. When you can see exactly how the software works, you never have to take our word for anything.",
|
||||
"paragraph2": "Open source also means Toju belongs to its community, not to a company. Even if we stopped development tomorrow, the project lives on. Your communication infrastructure shouldn't depend on a single organization's survival."
|
||||
}
|
||||
},
|
||||
"promise": {
|
||||
"title": "Our Promise",
|
||||
"items": {
|
||||
"noPaywalls": "We will <strong class=\"text-foreground\">never</strong> lock features behind a paywall.",
|
||||
"noDataSales": "We will <strong class=\"text-foreground\">never</strong> sell, monetize, or harvest your data.",
|
||||
"openSource": "We will <strong class=\"text-foreground\">always</strong> keep the source code open and auditable.",
|
||||
"usersBeforeProfit": "We will <strong class=\"text-foreground\">always</strong> put users before profit."
|
||||
},
|
||||
"signature": "- The Myxelium team"
|
||||
},
|
||||
"support": {
|
||||
"title": "Help us keep going",
|
||||
"description": "If Toju's mission resonates with you, consider supporting the project. Every contribution helps us keep the lights on and development moving forward - without ever compromising our values."
|
||||
}
|
||||
},
|
||||
"whatIsToju": {
|
||||
"seo": {
|
||||
"title": "What is Toju? - How It Works",
|
||||
"description": "Learn how Toju's peer-to-peer technology delivers free, private, unlimited voice calls, screen sharing, and file transfers without centralized servers."
|
||||
},
|
||||
"hero": {
|
||||
"badge": "The Big Picture",
|
||||
"titlePrefix": "What is",
|
||||
"description": "Toju is a communication app that lets you voice chat, share your screen, send files, and message your friends - all without your data passing through someone else's servers. Think of it as your own private phone line that nobody can tap into."
|
||||
},
|
||||
"howItWorks": {
|
||||
"titlePrefix": "How does it",
|
||||
"titleHighlight": "work"
|
||||
},
|
||||
"steps": {
|
||||
"one": {
|
||||
"title": "You connect directly to your friends",
|
||||
"description": "When you start a call or send a file on Toju, your data travels directly from your device to your friend's device. There's no company server in the middle storing your conversations, listening to your calls, or scanning your files. This is called <strong class=\"text-foreground\">peer-to-peer</strong> - it's like having a direct road between your houses instead of going through a toll booth."
|
||||
},
|
||||
"two": {
|
||||
"title": "A tiny helper gets you connected",
|
||||
"description": "The only thing a server does is help your device find your friend's device - like a mutual friend introducing you at a party. Once you're connected, the server steps out of the picture entirely. It never sees what you say, share, or send. This helper is called a <strong class=\"text-foreground\">signal server</strong>, and you can even run your own if you'd like."
|
||||
},
|
||||
"three": {
|
||||
"title": "No limits because there are no middlemen",
|
||||
"description": "Since your data doesn't pass through our servers, we don't need to pay for massive infrastructure. That's why Toju can offer <strong class=\"text-foreground\">unlimited screen sharing, file transfers of any size, and high-quality voice</strong> - all completely free. There's no business reason to limit what you can do, and we never will."
|
||||
}
|
||||
},
|
||||
"whyDesigned": {
|
||||
"titlePrefix": "Why is it",
|
||||
"titleHighlight": "designed",
|
||||
"titleSuffix": "this way?"
|
||||
},
|
||||
"benefits": {
|
||||
"privacyArchitecture": {
|
||||
"title": "Privacy by Architecture",
|
||||
"description": "We didn't just add privacy as a feature - we built the entire app around it. When there's no central server handling your data, there's nothing to hack, subpoena, or sell. Your privacy isn't protected by a promise; it's protected by how the technology works."
|
||||
},
|
||||
"performance": {
|
||||
"title": "Performance Without Compromise",
|
||||
"description": "Direct connections mean lower latency. Your voice reaches your friend faster. Your screen share is smoother. Your file arrives in the time it actually takes to transfer - not in the time it takes to upload, store, then download."
|
||||
},
|
||||
"sustainable": {
|
||||
"title": "Sustainable & Free",
|
||||
"description": "Running a traditional chat service with millions of users costs enormous amounts of money for servers. That cost gets passed to you. With peer-to-peer, the only infrastructure we run is a tiny coordination server - costing almost nothing. That's how we keep it free permanently."
|
||||
},
|
||||
"independence": {
|
||||
"title": "Independence & Freedom",
|
||||
"description": "You're not locked into our ecosystem. The code is open source. You can run your own server. If we ever disappeared tomorrow, you could still use Toju. Your communication tools should belong to you, not a corporation."
|
||||
}
|
||||
},
|
||||
"faq": {
|
||||
"titlePrefix": "Common",
|
||||
"titleHighlight": "Questions",
|
||||
"items": {
|
||||
"free": {
|
||||
"question": "Is Toju really free? What's the catch?",
|
||||
"answer": "Yes, it's really free. There is no catch. Because Toju uses peer-to-peer connections, we don't need expensive server infrastructure. Our costs are minimal, and we fund development through community support and donations. Every feature is available to everyone."
|
||||
},
|
||||
"technical": {
|
||||
"question": "Do I need technical knowledge to use Toju?",
|
||||
"answer": "Not at all. Toju works like any other chat app - download it, create an account, and start talking. All the peer-to-peer magic happens behind the scenes. You just enjoy the benefits of better privacy, performance, and no limits."
|
||||
},
|
||||
"selfHost": {
|
||||
"question": "What does \"self-host the signal server\" mean?",
|
||||
"answer": "The signal server is a tiny program that helps users find each other online. We run one by default, but if you prefer complete control, you can run your own copy on your own hardware. It's like having your own private phone directory - only people you invite can use it."
|
||||
},
|
||||
"safe": {
|
||||
"question": "Is my data safe?",
|
||||
"answer": "Your conversations, files, and calls go directly between you and the person you're talking to. They never pass through or get stored on our servers. Even if someone broke into our server, there would be nothing to find - because we never had your data in the first place."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cta": {
|
||||
"title": "Ready to try it?",
|
||||
"description": "Available on Windows, Linux, and in your browser. Always free."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
30
website/src/app/app.component.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/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';
|
||||
import translationsEn from '../../public/i18n/en.json';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [
|
||||
RouterOutlet,
|
||||
HeaderComponent,
|
||||
FooterComponent,
|
||||
ParticleBgComponent
|
||||
],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss'
|
||||
})
|
||||
export class AppComponent {
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
constructor() {
|
||||
this.translate.setTranslation('en', translationsEn);
|
||||
this.translate.setFallbackLang('en');
|
||||
this.translate.use('en');
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
23
website/src/app/app.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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 { provideTranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
provideRouter(
|
||||
routes,
|
||||
withInMemoryScrolling({ scrollPositionRestoration: 'top', anchorScrolling: 'enabled' })
|
||||
),
|
||||
provideClientHydration(withEventReplay()),
|
||||
provideHttpClient(withFetch()),
|
||||
provideTranslateService({
|
||||
fallbackLang: 'en',
|
||||
lang: 'en'
|
||||
})
|
||||
]
|
||||
};
|
||||
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"
|
||||
>
|
||||
{{ 'components.adSlot.label' | translate }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
13
website/src/app/components/ad-slot/ad-slot.component.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { AdService } from '../../services/ad.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ad-slot',
|
||||
standalone: true,
|
||||
imports: [TranslateModule],
|
||||
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"
|
||||
[attr.alt]="'common.brand' | translate"
|
||||
class="h-8 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed">
|
||||
{{ 'components.footer.description' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Product -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-foreground mb-4 uppercase tracking-wider">{{ 'components.footer.sections.product' | translate }}</h4>
|
||||
<ul class="space-y-3">
|
||||
<li>
|
||||
<a
|
||||
routerLink="/downloads"
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{{ 'components.footer.links.downloads' | translate }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://web.toju.app/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{{ 'components.footer.links.webVersion' | translate }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
routerLink="/what-is-toju"
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{{ 'components.footer.links.whatIsToju' | translate }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
routerLink="/gallery"
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{{ 'components.footer.links.imageGallery' | translate }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Community -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-foreground mb-4 uppercase tracking-wider">{{ 'components.footer.sections.community' | translate }}</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"
|
||||
/>
|
||||
{{ 'components.footer.links.sourceCode' | translate }}
|
||||
</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"
|
||||
/>
|
||||
{{ 'components.footer.links.github' | translate }}
|
||||
</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"
|
||||
/>
|
||||
{{ 'components.footer.links.supportUs' | translate }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Values -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-foreground mb-4 uppercase tracking-wider">{{ 'components.footer.sections.values' | translate }}</h4>
|
||||
<ul class="space-y-3">
|
||||
<li>
|
||||
<a
|
||||
routerLink="/philosophy"
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{{ 'components.footer.links.ourPhilosophy' | translate }}
|
||||
</a>
|
||||
</li>
|
||||
<li><span class="text-sm text-muted-foreground">{{ 'components.footer.values.freeForever' | translate }}</span></li>
|
||||
<li><span class="text-sm text-muted-foreground">{{ 'components.footer.values.openSource' | translate }}</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">{{ 'components.footer.copyright' | translate:{ year: currentYear } }}</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href="https://git.azaaxin.com/myxelium/Toju"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
[attr.aria-label]="'components.footer.viewSourceOnGitea' | translate"
|
||||
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"
|
||||
[attr.aria-label]="'components.footer.viewProjectOnGitHub' | translate"
|
||||
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>
|
||||
13
website/src/app/components/footer/footer.component.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-footer',
|
||||
standalone: true,
|
||||
imports: [RouterLink, TranslateModule],
|
||||
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="/"
|
||||
[attr.aria-label]="'components.header.homeAriaLabel' | translate"
|
||||
class="flex items-center group"
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<img
|
||||
src="/images/toju-logo-transparent.png"
|
||||
[attr.alt]="'common.brand' | translate"
|
||||
class="h-9 w-auto object-contain drop-shadow-lg group-hover:opacity-90 transition-opacity"
|
||||
/>
|
||||
<span class="text-xl font-bold text-foreground">{{ 'common.brand' | translate }}</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"
|
||||
>
|
||||
{{ 'components.header.beta' | translate }}
|
||||
</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"
|
||||
>
|
||||
{{ 'components.header.navigation.home' | translate }}
|
||||
</a>
|
||||
<a
|
||||
routerLink="/what-is-toju"
|
||||
routerLinkActive="text-primary"
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{{ 'components.header.navigation.whatIsToju' | translate }}
|
||||
</a>
|
||||
<a
|
||||
routerLink="/downloads"
|
||||
routerLinkActive="text-primary"
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{{ 'components.header.navigation.downloads' | translate }}
|
||||
</a>
|
||||
<a
|
||||
routerLink="/philosophy"
|
||||
routerLinkActive="text-primary"
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{{ 'components.header.navigation.philosophy' | translate }}
|
||||
</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"
|
||||
/>
|
||||
{{ 'components.header.supportUs' | translate }}
|
||||
</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"
|
||||
>
|
||||
{{ 'components.header.useWebVersion' | translate }}
|
||||
<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())"
|
||||
[attr.aria-label]="'components.header.toggleMenu' | translate"
|
||||
>
|
||||
<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"
|
||||
>{{ 'components.header.navigation.home' | translate }}</a
|
||||
>
|
||||
<a
|
||||
routerLink="/what-is-toju"
|
||||
class="block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>{{ 'components.header.navigation.whatIsToju' | translate }}</a
|
||||
>
|
||||
<a
|
||||
routerLink="/downloads"
|
||||
class="block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>{{ 'components.header.navigation.downloads' | translate }}</a
|
||||
>
|
||||
<a
|
||||
routerLink="/philosophy"
|
||||
class="block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>{{ 'components.header.navigation.philosophy' | translate }}</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"
|
||||
/>
|
||||
{{ 'components.header.supportUs' | translate }}
|
||||
</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"
|
||||
>
|
||||
{{ 'components.header.useWebVersion' | translate }}
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
34
website/src/app/components/header/header.component.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
HostListener,
|
||||
PLATFORM_ID
|
||||
} from '@angular/core';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-header',
|
||||
standalone: true,
|
||||
imports: [
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
TranslateModule
|
||||
],
|
||||
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">{{ 'pages.downloads.hero.titlePrefix' | translate }} <span class="gradient-text">{{ 'common.brand' | translate }}</span></h1>
|
||||
<p class="text-lg text-muted-foreground leading-relaxed">
|
||||
{{ 'pages.downloads.hero.description' | translate }}
|
||||
</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"
|
||||
>
|
||||
{{ 'pages.downloads.recommended.badge' | translate }}
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-foreground mb-2">{{ 'pages.downloads.recommended.title' | translate:{ os: getDetectedOsLabel() } }}</h2>
|
||||
<p class="text-muted-foreground mb-6">{{ 'pages.downloads.recommended.version' | translate:{ 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>
|
||||
{{ 'common.actions.downloadFor' | translate:{ os: getDetectedOsLabel() } }}
|
||||
</a>
|
||||
}
|
||||
|
||||
<p class="text-xs text-muted-foreground/60 mt-4">
|
||||
{{ 'pages.downloads.recommended.webVersionPrefix' | translate }}
|
||||
<a
|
||||
href="https://web.toju.app/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="underline hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
{{ 'pages.downloads.recommended.webVersionLink' | translate }}
|
||||
</a>
|
||||
{{ 'pages.downloads.recommended.webVersionSuffix' | translate }}
|
||||
</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">
|
||||
{{ 'pages.downloads.allPlatforms.title' | translate }} <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)"
|
||||
[attr.alt]="'pages.downloads.allPlatforms.assetIconAlt' | translate:{ os: getAssetOSLabel(asset.name) }"
|
||||
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">
|
||||
{{ getAssetOSLabel(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">{{ 'pages.downloads.previousReleases.title' | translate }}</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) }} · {{ 'pages.downloads.previousReleases.fileCount' | translate:{ count: release.assets.length } }}</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)"
|
||||
[attr.alt]="'pages.downloads.allPlatforms.assetIconAlt' | translate:{ os: getAssetOSLabel(asset.name) }"
|
||||
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>
|
||||
{{ 'pages.downloads.loading' | translate }}
|
||||
</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>
|
||||
{{ 'pages.downloads.rss.prefix' | translate }}
|
||||
<a
|
||||
href="https://git.azaaxin.com/myxelium/Toju/releases.rss"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="underline hover:text-foreground transition-colors"
|
||||
>
|
||||
{{ 'pages.downloads.rss.link' | translate }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
111
website/src/app/pages/downloads/downloads.component.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
AfterViewInit,
|
||||
OnDestroy,
|
||||
inject,
|
||||
PLATFORM_ID,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
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, TranslateModule],
|
||||
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>({
|
||||
key: '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);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.seoService.updateFromTranslations('pages.downloads.seo', {
|
||||
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);
|
||||
}
|
||||
|
||||
getDetectedOsLabel(): string {
|
||||
return this.translate.instant(`common.os.${this.detectedOS().key}`);
|
||||
}
|
||||
|
||||
getAssetOSLabel(name: string): string {
|
||||
return this.translate.instant(`common.os.${this.releaseService.getAssetOSKey(name)}`);
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
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"
|
||||
>
|
||||
{{ 'pages.gallery.hero.badge' | translate }}
|
||||
</div>
|
||||
<h1 class="text-4xl md:text-6xl font-extrabold text-foreground mb-6">{{ 'pages.gallery.hero.titlePrefix' | translate }} <span class="gradient-text">{{ 'common.brand' | translate }}</span></h1>
|
||||
<p class="text-lg text-muted-foreground leading-relaxed">
|
||||
{{ 'pages.gallery.hero.description' | translate }}
|
||||
</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"
|
||||
[attr.alt]="'pages.gallery.featured.imageAlt' | translate"
|
||||
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">{{ 'pages.gallery.featured.label' | translate }}</p>
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-2">{{ 'pages.gallery.featured.title' | translate }}</h2>
|
||||
<p class="max-w-2xl text-sm md:text-base text-muted-foreground">
|
||||
{{ 'pages.gallery.featured.description' | translate }}
|
||||
</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"
|
||||
[attr.alt]="item.titleKey | translate"
|
||||
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.titleKey | translate }}</h3>
|
||||
<p class="text-sm text-muted-foreground leading-relaxed">{{ item.descriptionKey | translate }}</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">{{ 'pages.gallery.cta.title' | translate }}</h2>
|
||||
<p class="text-muted-foreground leading-relaxed mb-6">{{ 'pages.gallery.cta.description' | translate }}</p>
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<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"
|
||||
>
|
||||
{{ 'common.actions.goToDownloads' | translate }}
|
||||
</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"
|
||||
>
|
||||
{{ 'common.actions.openWebVersion' | translate }}
|
||||
</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 { TranslateModule } from '@ngx-translate/core';
|
||||
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;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-gallery',
|
||||
standalone: true,
|
||||
imports: [
|
||||
NgOptimizedImage,
|
||||
TranslateModule,
|
||||
RouterLink,
|
||||
AdSlotComponent
|
||||
],
|
||||
templateUrl: './gallery.component.html'
|
||||
})
|
||||
export class GalleryComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
readonly galleryItems: GalleryItem[] = [
|
||||
{
|
||||
src: '/images/screenshots/screenshot_main.png',
|
||||
titleKey: 'pages.gallery.items.mainChatView.title',
|
||||
descriptionKey: 'pages.gallery.items.mainChatView.description'
|
||||
},
|
||||
{
|
||||
src: '/images/screenshots/screenshare_gaming.png',
|
||||
titleKey: 'pages.gallery.items.gamingScreenShare.title',
|
||||
descriptionKey: 'pages.gallery.items.gamingScreenShare.description'
|
||||
},
|
||||
{
|
||||
src: '/images/screenshots/serverViewScreen.png',
|
||||
titleKey: 'pages.gallery.items.serverOverview.title',
|
||||
descriptionKey: 'pages.gallery.items.serverOverview.description'
|
||||
},
|
||||
{
|
||||
src: '/images/screenshots/music.png',
|
||||
titleKey: 'pages.gallery.items.musicAndVoice.title',
|
||||
descriptionKey: 'pages.gallery.items.musicAndVoice.description'
|
||||
},
|
||||
{
|
||||
src: '/images/screenshots/videos.png',
|
||||
titleKey: 'pages.gallery.items.videoSharing.title',
|
||||
descriptionKey: 'pages.gallery.items.videoSharing.description'
|
||||
},
|
||||
{
|
||||
src: '/images/screenshots/filedownload.png',
|
||||
titleKey: 'pages.gallery.items.fileTransfers.title',
|
||||
descriptionKey: 'pages.gallery.items.fileTransfers.description'
|
||||
},
|
||||
{
|
||||
src: '/images/screenshots/gif.png',
|
||||
titleKey: 'pages.gallery.items.richMediaChat.title',
|
||||
descriptionKey: 'pages.gallery.items.richMediaChat.description'
|
||||
}
|
||||
];
|
||||
|
||||
private readonly seoService = inject(SeoService);
|
||||
private readonly scrollAnimation = inject(ScrollAnimationService);
|
||||
private readonly platformId = inject(PLATFORM_ID);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.seoService.updateFromTranslations('pages.gallery.seo', {
|
||||
url: 'https://toju.app/gallery'
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
if (isPlatformBrowser(this.platformId)) {
|
||||
setTimeout(() => this.scrollAnimation.init(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.scrollAnimation.destroy();
|
||||
}
|
||||
}
|
||||
538
website/src/app/pages/home/home.component.html
Normal file
@@ -0,0 +1,538 @@
|
||||
<!-- 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>
|
||||
{{ 'pages.home.hero.badge' | translate }}
|
||||
</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">{{ 'pages.home.hero.titleLine1' | translate }}</span><br />
|
||||
<span class="gradient-text">{{ 'pages.home.hero.titleLine2' | translate }}</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"
|
||||
>
|
||||
{{ 'pages.home.hero.description' | translate }}
|
||||
</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>
|
||||
{{ 'common.actions.downloadFor' | translate:{ os: getDetectedOsLabel() } }}
|
||||
<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>
|
||||
{{ 'common.actions.downloadBrand' | translate }}
|
||||
</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"
|
||||
>
|
||||
{{ 'common.actions.openInBrowser' | translate }}
|
||||
<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"
|
||||
>
|
||||
{{ 'pages.home.hero.version' | translate:{ version: latestVersion() } }} ·
|
||||
<a
|
||||
routerLink="/downloads"
|
||||
class="underline hover:text-muted-foreground transition-colors"
|
||||
>{{ 'pages.home.hero.allPlatforms' | translate }}</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">
|
||||
{{ 'pages.home.features.titleLine1' | translate }}<br />
|
||||
<span class="gradient-text">{{ 'pages.home.features.titleLine2' | translate }}</span>
|
||||
</h2>
|
||||
<p class="text-muted-foreground text-lg max-w-xl mx-auto">{{ 'pages.home.features.description' | translate }}</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">{{ 'pages.home.features.items.voiceCalls.title' | translate }}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
{{ 'pages.home.features.items.voiceCalls.description' | translate }}
|
||||
</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">{{ 'pages.home.features.items.screenSharing.title' | translate }}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
{{ 'pages.home.features.items.screenSharing.description' | translate }}
|
||||
</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">{{ 'pages.home.features.items.fileSharing.title' | translate }}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
{{ 'pages.home.features.items.fileSharing.description' | translate }}
|
||||
</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">{{ 'pages.home.features.items.privacy.title' | translate }}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
{{ 'pages.home.features.items.privacy.description' | translate }}
|
||||
</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">{{ 'pages.home.features.items.openSource.title' | translate }}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
{{ 'pages.home.features.items.openSource.description' | translate }}
|
||||
</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">{{ 'pages.home.features.items.free.title' | translate }}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
{{ 'pages.home.features.items.free.description' | translate }}
|
||||
</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>
|
||||
{{ 'pages.home.gaming.badge' | translate }}
|
||||
</div>
|
||||
<h2 class="text-3xl md:text-5xl font-bold text-foreground mb-6">
|
||||
{{ 'pages.home.gaming.titleLine1' | translate }}<br />
|
||||
<span class="gradient-text">{{ 'pages.home.gaming.titleLine2' | translate }}</span>
|
||||
</h2>
|
||||
<p class="text-lg text-muted-foreground leading-relaxed mb-8">
|
||||
{{ 'pages.home.gaming.description' | translate }}
|
||||
</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>{{ 'pages.home.gaming.bullets.lowLatency' | translate }}</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>{{ 'pages.home.gaming.bullets.noiseSuppression' | translate }}</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>{{ 'pages.home.gaming.bullets.screenShare' | translate }}</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>{{ 'pages.home.gaming.bullets.fileTransfers' | translate }}</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
|
||||
[attr.alt]="'pages.home.gaming.imageAlt' | translate"
|
||||
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">{{ 'pages.home.gaming.caption' | translate }}</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>
|
||||
{{ 'pages.home.selfHostable.badge' | translate }}
|
||||
</div>
|
||||
<h2 class="text-3xl md:text-5xl font-bold text-foreground mb-6">
|
||||
{{ 'pages.home.selfHostable.titleLine1' | translate }}<br />
|
||||
<span class="gradient-text">{{ 'pages.home.selfHostable.titleLine2' | translate }}</span>
|
||||
</h2>
|
||||
<p class="text-lg text-muted-foreground leading-relaxed mb-8">
|
||||
{{ 'pages.home.selfHostable.description' | translate }}
|
||||
</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"
|
||||
>
|
||||
{{ 'common.actions.learnHowItWorks' | translate }}
|
||||
<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"
|
||||
/>
|
||||
{{ 'common.actions.viewSourceCode' | translate }}
|
||||
</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">{{ 'pages.home.cta.title' | translate }}</h2>
|
||||
<p class="text-muted-foreground text-lg mb-8 max-w-lg mx-auto">{{ 'pages.home.cta.description' | translate }}</p>
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
@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"
|
||||
>
|
||||
{{ 'common.actions.downloadFor' | translate:{ os: getDetectedOsLabel() } }}
|
||||
</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"
|
||||
>
|
||||
{{ 'common.actions.downloadBrand' | translate }}
|
||||
</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"
|
||||
>
|
||||
{{ 'common.actions.tryInBrowser' | translate }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
80
website/src/app/pages/home/home.component.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
AfterViewInit,
|
||||
inject,
|
||||
PLATFORM_ID,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { isPlatformBrowser, NgOptimizedImage } from '@angular/common';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
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,
|
||||
TranslateModule,
|
||||
RouterLink,
|
||||
AdSlotComponent,
|
||||
ParallaxDirective
|
||||
],
|
||||
templateUrl: './home.component.html'
|
||||
})
|
||||
export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
readonly detectedOS = signal<DetectedOS>({
|
||||
key: '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);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.seoService.updateFromTranslations('pages.home.seo', {
|
||||
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();
|
||||
}
|
||||
|
||||
getDetectedOsLabel(): string {
|
||||
return this.translate.instant(`common.os.${this.detectedOS().key}`);
|
||||
}
|
||||
}
|
||||
190
website/src/app/pages/philosophy/philosophy.component.html
Normal file
@@ -0,0 +1,190 @@
|
||||
<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"
|
||||
>
|
||||
{{ 'pages.philosophy.hero.badge' | translate }}
|
||||
</div>
|
||||
<h1 class="text-4xl md:text-6xl font-extrabold text-foreground mb-6">{{ 'pages.philosophy.hero.titlePrefix' | translate }} <span class="gradient-text">{{ 'pages.philosophy.hero.titleHighlight' | translate }}</span> {{ 'common.brand' | translate }}</h1>
|
||||
<p class="text-lg text-muted-foreground leading-relaxed">{{ 'pages.philosophy.hero.description' | translate }}</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">{{ 'pages.philosophy.sections.ownership.title' | translate }}</h2>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
{{ 'pages.philosophy.sections.ownership.paragraph1' | translate }}
|
||||
</p>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
{{ 'pages.philosophy.sections.ownership.paragraph2' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- No predatory pricing -->
|
||||
<div class="section-fade mb-16">
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">{{ 'pages.philosophy.sections.paywalls.title' | translate }}</h2>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
{{ 'pages.philosophy.sections.paywalls.paragraph1' | translate }}
|
||||
</p>
|
||||
<p class="text-muted-foreground leading-relaxed" [innerHTML]="'pages.philosophy.sections.paywalls.paragraph2' | translate"></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">{{ 'pages.philosophy.sections.privacy.title' | translate }}</h2>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
{{ 'pages.philosophy.sections.privacy.paragraph1' | translate }}
|
||||
</p>
|
||||
<p class="text-muted-foreground leading-relaxed" [innerHTML]="'pages.philosophy.sections.privacy.paragraph2' | translate"></p>
|
||||
</div>
|
||||
|
||||
<!-- Better world -->
|
||||
<div class="section-fade mb-16">
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">{{ 'pages.philosophy.sections.heart.title' | translate }}</h2>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
{{ 'pages.philosophy.sections.heart.paragraph1' | translate }}
|
||||
</p>
|
||||
<p class="text-muted-foreground leading-relaxed" [innerHTML]="'pages.philosophy.sections.heart.paragraph2' | translate"></p>
|
||||
</div>
|
||||
|
||||
<!-- Open source -->
|
||||
<div class="section-fade mb-16">
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">{{ 'pages.philosophy.sections.openSource.title' | translate }}</h2>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
{{ 'pages.philosophy.sections.openSource.paragraph1' | translate }}
|
||||
</p>
|
||||
<p class="text-muted-foreground leading-relaxed">
|
||||
{{ 'pages.philosophy.sections.openSource.paragraph2' | translate }}
|
||||
</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">{{ 'pages.philosophy.promise.title' | translate }}</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 [innerHTML]="'pages.philosophy.promise.items.noPaywalls' | translate"></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 [innerHTML]="'pages.philosophy.promise.items.noDataSales' | translate"></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 [innerHTML]="'pages.philosophy.promise.items.openSource' | translate"></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 [innerHTML]="'pages.philosophy.promise.items.usersBeforeProfit' | translate"></span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-muted-foreground mt-6 text-sm">{{ 'pages.philosophy.promise.signature' | translate }}</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">{{ 'pages.philosophy.support.title' | translate }}</h2>
|
||||
<p class="text-muted-foreground mb-8 leading-relaxed">
|
||||
{{ 'pages.philosophy.support.description' | translate }}
|
||||
</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"
|
||||
/>
|
||||
{{ 'common.actions.buyUsCoffee' | translate }}
|
||||
</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"
|
||||
>
|
||||
{{ 'common.actions.downloadBrand' | translate }}
|
||||
<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>
|
||||
46
website/src/app/pages/philosophy/philosophy.component.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
AfterViewInit,
|
||||
OnDestroy,
|
||||
inject,
|
||||
PLATFORM_ID
|
||||
} from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
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,
|
||||
TranslateModule
|
||||
],
|
||||
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.updateFromTranslations('pages.philosophy.seo', {
|
||||
url: 'https://toju.app/philosophy'
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
if (isPlatformBrowser(this.platformId)) {
|
||||
setTimeout(() => this.scrollAnimation.init(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.scrollAnimation.destroy();
|
||||
}
|
||||
}
|
||||
238
website/src/app/pages/what-is-toju/what-is-toju.component.html
Normal file
@@ -0,0 +1,238 @@
|
||||
<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"
|
||||
>
|
||||
{{ 'pages.whatIsToju.hero.badge' | translate }}
|
||||
</div>
|
||||
<h1 class="text-4xl md:text-6xl font-extrabold text-foreground mb-6">{{ 'pages.whatIsToju.hero.titlePrefix' | translate }} <span class="gradient-text">{{ 'common.brand' | translate }}</span>?</h1>
|
||||
<p class="text-lg text-muted-foreground leading-relaxed">
|
||||
{{ 'pages.whatIsToju.hero.description' | translate }}
|
||||
</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">
|
||||
{{ 'pages.whatIsToju.howItWorks.titlePrefix' | translate }} <span class="gradient-text">{{ 'pages.whatIsToju.howItWorks.titleHighlight' | translate }}</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">{{ 'pages.whatIsToju.steps.one.title' | translate }}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed" [innerHTML]="'pages.whatIsToju.steps.one.description' | translate"></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">{{ 'pages.whatIsToju.steps.two.title' | translate }}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed" [innerHTML]="'pages.whatIsToju.steps.two.description' | translate"></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">{{ 'pages.whatIsToju.steps.three.title' | translate }}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed" [innerHTML]="'pages.whatIsToju.steps.three.description' | translate"></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">
|
||||
{{ 'pages.whatIsToju.whyDesigned.titlePrefix' | translate }} <span class="gradient-text">{{ 'pages.whatIsToju.whyDesigned.titleHighlight' | translate }}</span> {{ 'pages.whatIsToju.whyDesigned.titleSuffix' | translate }}
|
||||
</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">{{ 'pages.whatIsToju.benefits.privacyArchitecture.title' | translate }}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed text-sm">
|
||||
{{ 'pages.whatIsToju.benefits.privacyArchitecture.description' | translate }}
|
||||
</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">{{ 'pages.whatIsToju.benefits.performance.title' | translate }}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed text-sm">
|
||||
{{ 'pages.whatIsToju.benefits.performance.description' | translate }}
|
||||
</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">{{ 'pages.whatIsToju.benefits.sustainable.title' | translate }}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed text-sm">
|
||||
{{ 'pages.whatIsToju.benefits.sustainable.description' | translate }}
|
||||
</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">{{ 'pages.whatIsToju.benefits.independence.title' | translate }}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed text-sm">
|
||||
{{ 'pages.whatIsToju.benefits.independence.description' | translate }}
|
||||
</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">{{ 'pages.whatIsToju.faq.titlePrefix' | translate }} <span class="gradient-text">{{ 'pages.whatIsToju.faq.titleHighlight' | translate }}</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">{{ 'pages.whatIsToju.faq.items.free.question' | translate }}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed text-sm">
|
||||
{{ 'pages.whatIsToju.faq.items.free.answer' | translate }}
|
||||
</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">{{ 'pages.whatIsToju.faq.items.technical.question' | translate }}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed text-sm">
|
||||
{{ 'pages.whatIsToju.faq.items.technical.answer' | translate }}
|
||||
</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">{{ 'pages.whatIsToju.faq.items.selfHost.question' | translate }}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed text-sm">
|
||||
{{ 'pages.whatIsToju.faq.items.selfHost.answer' | translate }}
|
||||
</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">{{ 'pages.whatIsToju.faq.items.safe.question' | translate }}</h3>
|
||||
<p class="text-muted-foreground leading-relaxed text-sm">
|
||||
{{ 'pages.whatIsToju.faq.items.safe.answer' | translate }}
|
||||
</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">{{ 'pages.whatIsToju.cta.title' | translate }}</h2>
|
||||
<p class="text-muted-foreground mb-8">{{ 'pages.whatIsToju.cta.description' | translate }}</p>
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<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"
|
||||
>
|
||||
{{ 'common.actions.downloadBrand' | translate }}
|
||||
</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"
|
||||
>
|
||||
{{ 'common.actions.openInBrowser' | translate }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
46
website/src/app/pages/what-is-toju/what-is-toju.component.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
AfterViewInit,
|
||||
OnDestroy,
|
||||
inject,
|
||||
PLATFORM_ID
|
||||
} from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
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,
|
||||
TranslateModule
|
||||
],
|
||||
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.updateFromTranslations('pages.whatIsToju.seo', {
|
||||
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);
|
||||
}
|
||||
}
|
||||
253
website/src/app/services/release.service.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
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 type OsKey = 'windows' | 'macos' | 'linux' | 'linuxDebian' | 'archive' | 'web' | 'other';
|
||||
|
||||
export interface DetectedOS {
|
||||
key: 'windows' | 'macos' | 'linux' | 'linuxDebian';
|
||||
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 {
|
||||
key: 'linux',
|
||||
icon: null,
|
||||
filePattern: /\.AppImage$/i,
|
||||
ymlFile: 'latest-linux.yml'
|
||||
};
|
||||
}
|
||||
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
|
||||
if (userAgent.includes('win')) {
|
||||
return {
|
||||
key: 'windows',
|
||||
icon: null,
|
||||
filePattern: /\.exe$/i,
|
||||
ymlFile: 'latest.yml'
|
||||
};
|
||||
}
|
||||
|
||||
if (userAgent.includes('mac')) {
|
||||
return {
|
||||
key: 'macos',
|
||||
icon: null,
|
||||
filePattern: /\.dmg$/i,
|
||||
ymlFile: 'latest-mac.yml'
|
||||
};
|
||||
}
|
||||
|
||||
const isUbuntuDebian = userAgent.includes('ubuntu') || userAgent.includes('debian');
|
||||
|
||||
if (isUbuntuDebian) {
|
||||
return {
|
||||
key: 'linuxDebian',
|
||||
icon: null,
|
||||
filePattern: /\.deb$/i,
|
||||
ymlFile: 'latest-linux.yml'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: '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;
|
||||
}
|
||||
|
||||
getAssetOSKey(name: string): OsKey {
|
||||
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();
|
||||
}
|
||||
}
|
||||