Files
R3ap3r 3178b8f4f7 Aurora SpotiRip desktop and web app
- Spotify playlist, library, album and artist browsing with in-app playback
- Song, playlist and album downloads via spotdl with YouTube and Bandcamp fallbacks
- Electron desktop app (castLabs Widevine) with a permanent sidebar layout
- Linux and Windows packaging scripts
2026-09-25 23:20:57 -05:00

143 lines
4.5 KiB
TypeScript

import "./env";
import { app, BrowserWindow, components, dialog, ipcMain, session, shell } from "electron";
import { join } from "node:path";
import { startApiServer } from "./apiServer";
import { PORT } from "./constants";
import { applySettings, loadSettings, saveSettings } from "./settings";
const APP_URL = `http://127.0.0.1:${PORT}`;
const LOGIN_HOSTS = ["spotify.com", "google.com", "facebook.com", "apple.com"];
let mainWindow: BrowserWindow | undefined;
if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
app.on("second-instance", () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
});
app.whenReady().then(start);
}
async function start() {
app.userAgentFallback = app.userAgentFallback.replace(/ (Electron|aurora-spotirip|Aurora SpotiRip)\/\S+/g, "");
applySettings();
registerIpc();
try {
await startApiServer(PORT, join(app.getAppPath(), "dist"), { onSpotifyLogin: adoptSpotifyLogin });
} catch (error) {
const inUse = (error as NodeJS.ErrnoException).code === "EADDRINUSE";
dialog.showErrorBox(
"Aurora SpotiRip could not start",
inUse
? `Port ${PORT} is already in use by another program. Close it and start Aurora SpotiRip again.`
: String(error),
);
app.quit();
return;
}
try {
await components.whenReady();
} catch (error) {
console.error("Widevine is unavailable; in-app playback will use Spotify Connect instead.", error);
}
createWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1360,
height: 880,
minWidth: 960,
minHeight: 600,
backgroundColor: "#0f1318",
title: "Aurora SpotiRip",
icon: join(app.getAppPath(), "build", "icon.png"),
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, "preload.cjs"),
contextIsolation: true,
nodeIntegration: false,
},
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: "deny" };
});
mainWindow.webContents.on("will-navigate", (event, url) => {
const { origin, hostname, pathname } = new URL(url);
if (origin === APP_URL && pathname === "/_api/spotify-auth") {
event.preventDefault();
shell.openExternal(url);
return;
}
if (origin === APP_URL || LOGIN_HOSTS.some((host) => hostname === host || hostname.endsWith(`.${host}`))) return;
event.preventDefault();
shell.openExternal(url);
});
mainWindow.webContents.on("console-message", (event) => {
if (event.level === "warning" || event.level === "error") {
console.log(`[page ${event.level}] ${event.message}`);
}
});
mainWindow.loadURL(APP_URL);
mainWindow.on("closed", () => {
mainWindow = undefined;
});
}
async function adoptSpotifyLogin(setCookies: string[]) {
for (const header of setCookies) {
const [pair, ...attributes] = header.split(";").map((part) => part.trim());
const index = pair.indexOf("=");
const name = pair.slice(0, index);
const value = decodeURIComponent(pair.slice(index + 1));
const maxAge = Number(attributes.find((attr) => /^max-age=/i.test(attr))?.split("=")[1]);
if (!name || name === "spotirip_oauth_state") continue;
await session.defaultSession.cookies.set({
url: APP_URL,
name,
value,
path: "/",
secure: true,
httpOnly: attributes.some((attr) => /^httponly$/i.test(attr)),
sameSite: "lax",
...(Number.isFinite(maxAge) ? { expirationDate: Date.now() / 1000 + maxAge } : {}),
});
}
if (!mainWindow) createWindow();
else {
mainWindow.loadURL(APP_URL);
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
}
function registerIpc() {
ipcMain.handle("settings:get-download-folder", () => loadSettings().downloadFolder);
ipcMain.handle("settings:choose-download-folder", async () => {
const result = await dialog.showOpenDialog(mainWindow!, {
title: "Choose download folder",
defaultPath: loadSettings().downloadFolder,
properties: ["openDirectory", "createDirectory"],
});
if (result.canceled || !result.filePaths[0]) return undefined;
return saveSettings({ downloadFolder: result.filePaths[0] }).downloadFolder;
});
ipcMain.handle("shell:show-in-folder", (_event, path: string) => shell.showItemInFolder(path));
}
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});