Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
285 changes: 264 additions & 21 deletions src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@ import { Preconditions } from "@common/preconditions";
import { DolphinLaunchType } from "@dolphin/types";
import { ipc_statsPageRequestedEvent } from "@replays/ipc";
import { ipc_openSettingsModalEvent } from "@settings/ipc";
import { SlippiGame } from "@slippi/slippi-js";
import type CrossProcessExports from "electron";
import { app, BrowserWindow, shell } from "electron";
import log from "electron-log";
import { autoUpdater } from "electron-updater";
import * as fs from "fs-extra";
import * as http from "http";
import * as https from "https";
import get from "lodash/get";
import last from "lodash/last";
import path from "path";
Expand Down Expand Up @@ -146,7 +149,7 @@ const createWindow = async () => {
onOpenPreferences: () => {
void openPreferences().catch(log.error);
},
onOpenReplayFile: playReplayAndShowStats,
onOpenReplayFile: (filePath: string) => playReplayAndShowStats(filePath, false),
createWindow,
onOpenAppSupportFolder: () => {
const path = app.getPath("userData");
Expand All @@ -172,6 +175,13 @@ const createWindow = async () => {
*/

app.on("window-all-closed", () => {
// Clean up any active streams
activeStreams.forEach((timeoutId, destination) => {
clearTimeout(timeoutId);
log.info(`Cleaned up stream for ${destination}`);
});
activeStreams.clear();

// On macOS, the window closing shouldn't quit the actual process.
// Instead, grab and activate a hidden menu item to enable the user to
// recreate the window on-demand.
Expand Down Expand Up @@ -204,6 +214,184 @@ const waitForMainWindow = async () => {
log.info(`Found mainWindow after ${retryIdx} tries.`);
};

// Track active streams for cleanup
const activeStreams = new Map<string, NodeJS.Timeout>();

/**
* Streams a replay file continuously for mirror mode.
* Downloads the file in chunks and updates it as new data becomes available.
* Stops streaming when the Game End event (0x39) is detected.
*/
const startReplayStream = async (url: string, destination: string): Promise<void> => {
log.info(`Starting replay stream from ${url} to ${destination}`);

let lastSize = 0;
let gameEndDetected = false;
const streamInterval = 500; // Check for updates every 500ms
let consecutiveErrorCount = 0;
const maxConsecutiveErrors = 10; // Stop after 10 consecutive errors (5 seconds of errors)

// Initial download to get the file started
try {
await download({ url, destinationFile: destination, overwrite: true });
const stats = await fs.stat(destination);
lastSize = stats.size;
log.info(`Initial download complete, file size: ${lastSize} bytes`);
} catch (err) {
log.error(`Failed to start initial download: ${err}`);
throw err;
}

// Function to check if Game End event is present using SlippiGame
const checkForGameEnd = async (): Promise<boolean> => {
try {
// Use SlippiGame with processOnTheFly to handle incomplete files
const game = new SlippiGame(destination, { processOnTheFly: true });
const gameEnd = game.getGameEnd();

if (gameEnd) {
const endTypes = {
1: "TIME!",
2: "GAME!",
7: "No Contest",
};
const endMessage = endTypes[gameEnd.gameEndMethod as keyof typeof endTypes] || "Unknown";
log.info(`Game End detected: ${endMessage}`);
return true;
}

return false;
} catch (err) {
// This is expected for incomplete files during streaming
log.debug(`Game end check failed (expected during streaming): ${err}`);
return false;
}
};

// Start continuous streaming in the background
const streamLoop = async () => {
try {
// Check if game has ended before making another request
if (gameEndDetected) {
log.info(`Game has ended, stopping stream for ${url}`);
activeStreams.delete(destination);
return;
}

// Use HTTP range request to get only new data
const urlObj = new URL(url);
const httpModule = urlObj.protocol === "https:" ? https : http;

const options = {
hostname: urlObj.hostname,
port: urlObj.port,
path: urlObj.pathname + urlObj.search,
method: "GET",
headers: {
Range: `bytes=${lastSize}-`,
},
};

const req = httpModule.request(options, (res) => {
if (res.statusCode === 206 || res.statusCode === 200) {
consecutiveErrorCount = 0; // Reset error count on successful response
const chunks: Buffer[] = [];

res.on("data", (chunk: Buffer) => {
chunks.push(chunk);
});

res.on("end", async () => {
if (chunks.length > 0) {
const newData = Buffer.concat(chunks);
if (newData.length > 0) {
// Append new data to the file
await fs.appendFile(destination, newData);
lastSize += newData.length;
log.debug(`Streamed ${newData.length} new bytes, total size: ${lastSize}`);

// Check if the game has ended
gameEndDetected = await checkForGameEnd();
if (gameEndDetected) {
log.info(`Game End detected, stopping stream for ${url}`);
activeStreams.delete(destination);
return;
}
}
}

// Continue streaming
const timeoutId = setTimeout(streamLoop, streamInterval);
activeStreams.set(destination, timeoutId);
});
} else {
// Continue streaming even if no new data as the game could be paused
const timeoutId = setTimeout(streamLoop, streamInterval);
activeStreams.set(destination, timeoutId);
}
});

req.on("error", (err) => {
log.error(`Stream request failed: ${err}`);
consecutiveErrorCount++;

// Stop streaming if too many consecutive errors
if (consecutiveErrorCount >= maxConsecutiveErrors) {
log.info(`Stopping stream for ${url} - too many consecutive errors (${consecutiveErrorCount})`);
activeStreams.delete(destination);
return;
}

// Continue streaming despite errors
const timeoutId = setTimeout(streamLoop, streamInterval);
activeStreams.set(destination, timeoutId);
});

req.end();
} catch (err) {
log.error(`Stream update failed: ${err}`);
consecutiveErrorCount++;

// Stop streaming if too many consecutive errors
if (consecutiveErrorCount >= maxConsecutiveErrors) {
log.info(`Stopping stream for ${url} - too many consecutive errors (${consecutiveErrorCount})`);
activeStreams.delete(destination);
return;
}

// Continue streaming despite errors
const timeoutId = setTimeout(streamLoop, streamInterval);
activeStreams.set(destination, timeoutId);
}
};

// Start the streaming loop after a brief delay to let Dolphin start
const initialTimeoutId = setTimeout(streamLoop, 1000);
activeStreams.set(destination, initialTimeoutId);
};

/**
* Stops streaming for a specific destination file
*/
const stopReplayStream = (destination: string): void => {
const timeoutId = activeStreams.get(destination);
if (timeoutId) {
clearTimeout(timeoutId);
activeStreams.delete(destination);
log.info(`Stopped streaming for ${destination}`);
}
};

/**
* Handles slippi:// protocol URLs for opening remote replays.
* Supports the following parameters:
* - path: The path to the replay file (required)
* - mirror: Set to "true" or "1" to launch in mirror mode (optional, defaults to normal mode)
*
* Examples:
* - slippi://path=replay.slp (normal mode)
* - slippi://path=replay.slp&mirror=true (mirror mode)
*/
const handleSlippiURIAsync = async (aUrl: string) => {
log.info("Handling URL...");
log.info(aUrl);
Expand Down Expand Up @@ -238,33 +426,56 @@ const handleSlippiURIAsync = async (aUrl: string) => {
if (!replayPath) {
return;
}
// For some reason the file refuses to download if it's prefixed with "/"
if (replayPath[0] === "/") {
replayPath = replayPath.slice(1);
}
// Check if mirror mode is requested
const mirrorParam = myUrl.searchParams.get("mirror");
const isMirror = mirrorParam === "true" || mirrorParam === "1";

const tmpDir = path.join(app.getPath("userData"), "temp");
await fs.ensureDir(tmpDir);
const destination = path.join(tmpDir, path.basename(replayPath));
log.info(`Mirror mode requested: ${isMirror}`);

const fileAlreadyExists = await fileExists(destination);
if (!fileAlreadyExists) {
const dlUrl = replayPath.startsWith("http")
if (isMirror) {
// For mirror mode, we need to stream the replay continuously
const streamUrl = replayPath.startsWith("http")
? replayPath
: `https://storage.googleapis.com/slippi.appspot.com/${replayPath}`;
log.info(`Downloading file ${replayPath} to ${destination}`);
// Dowload file
await download({ url: dlUrl, destinationFile: destination, overwrite: true });
log.info(`Finished download`);
log.info(`Streaming replay in mirror mode: ${streamUrl}`);

const tmpDir = path.join(app.getPath("userData"), "temp");
await fs.ensureDir(tmpDir);
const destination = path.join(tmpDir, `mirror_${Date.now()}_${path.basename(replayPath)}`);

// Start streaming the replay file
await startReplayStream(streamUrl, destination);
await playReplayAndShowStats(destination, isMirror);
} else {
log.info(`${destination} already exists. Skipping download...`);
// For normal mode, download the file first
// For some reason the file refuses to download if it's prefixed with "/"
if (replayPath[0] === "/") {
replayPath = replayPath.slice(1);
}

const tmpDir = path.join(app.getPath("userData"), "temp");
await fs.ensureDir(tmpDir);
const destination = path.join(tmpDir, path.basename(replayPath));

const fileAlreadyExists = await fileExists(destination);
if (!fileAlreadyExists) {
const dlUrl = replayPath.startsWith("http")
? replayPath
: `https://storage.googleapis.com/slippi.appspot.com/${replayPath}`;
log.info(`Downloading file ${replayPath} to ${destination}`);
// Download file
await download({ url: dlUrl, destinationFile: destination, overwrite: true });
log.info(`Finished download`);
} else {
log.info(`${destination} already exists. Skipping download...`);
}
await playReplayAndShowStats(destination, isMirror);
}
await playReplayAndShowStats(destination);
break;
}
case "file:": {
log.info(myUrl.pathname);
await playReplayAndShowStats(aUrl);
await playReplayAndShowStats(aUrl, false);
break;
}
default: {
Expand Down Expand Up @@ -307,17 +518,39 @@ app.on("second-instance", (_, argv) => {
handleSlippiURI(lastItem);
});

const playReplayAndShowStats = async (filePath: string) => {
const playReplayAndShowStats = async (filePath: string, mirror = false) => {
// Ensure playback dolphin is actually installed
await dolphinManager.installDolphin(DolphinLaunchType.PLAYBACK);

log.info(`Launching replay in ${mirror ? "mirror" : "normal"} mode: ${filePath}`);

// For mirror mode, set up a listener to stop streaming when Dolphin closes
if (mirror) {
const stopStreamingOnDolphinClose = (event: any) => {
if (event.dolphinType === DolphinLaunchType.PLAYBACK && event.instanceId === "playback") {
log.info("Dolphin closed, stopping stream...");
stopReplayStream(filePath);
// Unsubscribe from future events
subscription.unsubscribe();
}
};

// Subscribe to dolphin events to detect when it closes
const subscription = dolphinManager.events.subscribe(stopStreamingOnDolphinClose);
}

// Launch the replay
await dolphinManager.launchPlaybackDolphin("playback", {
mode: "normal",
mode: mirror ? "mirror" : "normal",
replay: filePath,
});

// Show the stats page
// For mirror mode, wait a bit longer before showing stats to ensure some data is available
if (mirror) {
log.info("Waiting for initial stream data before showing stats...");
await delay(2000); // Wait 2 seconds for some data to be streamed
}

await waitForMainWindow();
if (mainWindow) {
await ipc_statsPageRequestedEvent.main!.trigger({ filePath });
Expand Down Expand Up @@ -349,6 +582,16 @@ app
})
.catch(log.error);

// Clean up streams on app quit
app.on("before-quit", () => {
log.info("App is quitting, cleaning up active streams...");
activeStreams.forEach((timeoutId, destination) => {
clearTimeout(timeoutId);
log.info(`Cleaned up stream for ${destination}`);
});
activeStreams.clear();
});

const openPreferences = async () => {
if (!mainWindow) {
await createWindow();
Expand Down