From 601d3f51f45f0b46496db8123558047cea43b193 Mon Sep 17 00:00:00 2001 From: SunnyPai0413 Date: Wed, 17 Jun 2026 12:51:44 +0800 Subject: [PATCH 1/3] add nixos support --- .gitignore | 4 + electron/main/backend/install-manager.ts | 60 +++++--- electron/main/backend/shared-installer.ts | 40 +++++- nix/package.nix | 158 ++++++++++++++++++++++ 4 files changed, 238 insertions(+), 24 deletions(-) create mode 100644 nix/package.nix diff --git a/.gitignore b/.gitignore index f4a7205f..0af92e01 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,10 @@ dist-electron release *.local +# Nix build outputs +/result +/result-* + # Local secrets and databases .env .env.*.local diff --git a/electron/main/backend/install-manager.ts b/electron/main/backend/install-manager.ts index 21ee42ca..5ddc05f0 100644 --- a/electron/main/backend/install-manager.ts +++ b/electron/main/backend/install-manager.ts @@ -1,6 +1,5 @@ import { ipcMain, WebContents } from "electron"; -import { spawn, ChildProcess, exec } from "node:child_process"; -import { promisify } from "node:util"; +import { spawn, ChildProcess } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import pino from "pino"; @@ -44,28 +43,55 @@ type InstallTask = { }; const SHELL_CALLER_PATH = "/opt/spark-store/extras/shell-caller.sh"; +const SUPER_USER_COMMAND_CANDIDATES = [ + "/usr/bin/pkexec", + "/run/wrappers/bin/pkexec", + "pkexec", +]; export const tasks = new Map(); let idle = true; // Indicates if the installation manager is idle -export const checkSuperUserCommand = async (): Promise => { - let superUserCmd = ""; - const execAsync = promisify(exec); - if (process.getuid && process.getuid() !== 0) { - const { stdout, stderr } = await execAsync("which /usr/bin/pkexec"); - if (stderr) { - logger.error("没有找到 pkexec 命令"); - return; - } - logger.info(`找到提升权限命令: ${stdout.trim()}`); - superUserCmd = stdout.trim(); - - if (superUserCmd.length === 0) { - logger.error("没有找到提升权限的命令 pkexec!"); +const findExecutable = async (command: string): Promise => { + if (path.isAbsolute(command)) { + try { + await fs.promises.access(command, fs.constants.X_OK); + return command; + } catch { + return ""; } } - return superUserCmd; + + return await new Promise((resolve) => { + const child = spawn("which", [command]); + let stdout = ""; + + child.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + child.on("close", (code) => { + resolve(code === 0 ? stdout.trim() : ""); + }); + child.on("error", () => { + resolve(""); + }); + }); +}; + +export const checkSuperUserCommand = async (): Promise => { + if (process.getuid?.() === 0) return ""; + + for (const command of SUPER_USER_COMMAND_CANDIDATES) { + const superUserCmd = await findExecutable(command); + if (superUserCmd.length > 0) { + logger.info(`找到提升权限命令: ${superUserCmd}`); + return superUserCmd; + } + } + + logger.error("没有找到提升权限的命令 pkexec!"); + return ""; }; const runCommandCapture = async (execCommand: string, execParams: string[]) => { diff --git a/electron/main/backend/shared-installer.ts b/electron/main/backend/shared-installer.ts index 83f1f59c..806d0fa4 100644 --- a/electron/main/backend/shared-installer.ts +++ b/electron/main/backend/shared-installer.ts @@ -12,6 +12,11 @@ import pino from "pino"; const logger = pino({ name: "shared-installer" }); export const SHELL_CALLER_PATH = "/opt/spark-store/extras/shell-caller.sh"; +const SUPER_USER_COMMAND_CANDIDATES = [ + "/usr/bin/pkexec", + "/run/wrappers/bin/pkexec", + "pkexec", +]; export interface DownloadOptions { pkgname: string; @@ -345,18 +350,39 @@ export const checkApmAvailable = async (): Promise => { * 检查提权命令 */ export const checkSuperUserCommand = async (): Promise => { - return new Promise((resolve) => { - const child = spawn("which", ["/usr/bin/pkexec"]); + if (process.getuid?.() === 0) return ""; + + for (const command of SUPER_USER_COMMAND_CANDIDATES) { + const superUserCmd = await findExecutable(command); + if (superUserCmd.length > 0) { + logger.info(`找到提升权限命令: ${superUserCmd}`); + return superUserCmd; + } + } + + logger.error("没有找到提升权限的命令 pkexec!"); + return ""; +}; + +const findExecutable = async (command: string): Promise => { + if (path.isAbsolute(command)) { + try { + await fs.promises.access(command, fs.constants.X_OK); + return command; + } catch { + return ""; + } + } + + return await new Promise((resolve) => { + const child = spawn("which", [command]); let stdout = ""; + child.stdout?.on("data", (data) => { stdout += data.toString(); }); child.on("close", (code) => { - if (code === 0) { - resolve(stdout.trim()); - } else { - resolve(""); - } + resolve(code === 0 ? stdout.trim() : ""); }); child.on("error", () => { resolve(""); diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 00000000..a0746ef0 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,158 @@ +{ + lib, + buildNpmPackage, + importNpmLock, + electron, + makeWrapper, + aria2, + apm, + coreutils, + gnugrep, + which, + xdg-utils, + bash, +}: + +buildNpmPackage rec { + pname = "spark-store"; + version = "5.1.1"; + + src = lib.cleanSourceWith { + src = ../.; + filter = + path: type: + let + baseName = baseNameOf path; + in + !(lib.elem baseName [ + ".git" + "dist" + "dist-electron" + "node_modules" + "release" + "result" + ]); + }; + + npmDeps = importNpmLock { + npmRoot = ../.; + }; + npmConfigHook = importNpmLock.npmConfigHook; + + nativeBuildInputs = [ + makeWrapper + ]; + + env = { + ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1"; + }; + + buildPhase = '' + runHook preBuild + + npm run build:vite + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + appDir="$out/share/spark-store" + + npm prune --omit=dev --ignore-scripts + + substituteInPlace extras/shell-caller.sh \ + --replace-fail "/usr/bin/apm" "${lib.getExe' apm "apm"}" + + mkdir -p "$appDir" "$out/bin" + cp -r dist dist-electron package.json node_modules extras icons "$appDir"/ + + chmod -R u+w "$appDir" + find "$appDir/extras" -type f -exec chmod +x {} \; + + substituteInPlace "$appDir/dist-electron/main/index.js" \ + --replace-fail "/opt/spark-store/extras/shell-caller.sh" "$appDir/extras/shell-caller.sh" \ + --replace-fail "/opt/spark-store/extras/app-launcher" "$appDir/extras/app-launcher" + + install -Dm644 pkg/usr/share/applications/spark-store.desktop \ + "$out/share/applications/spark-store.desktop" + + install -Dm644 icons/spark-store.svg \ + "$out/share/icons/hicolor/scalable/apps/spark-store.svg" + + install -Dm644 icons/spark-store.png \ + "$out/share/icons/hicolor/512x512/apps/spark-store.png" + + install -Dm644 extras/store.spark-app.spark-store.policy \ + "$out/share/polkit-1/actions/store.spark-app.spark-store.policy" + + substituteInPlace "$out/share/polkit-1/actions/store.spark-app.spark-store.policy" \ + --replace-fail "/opt/spark-store/extras/shell-caller.sh" "$appDir/extras/shell-caller.sh" + + cat > "$out/bin/spark-store" </dev/null 2>&1; then + app_args+=(--no-spark) +fi + +if [ -r /etc/os-release ] && ${gnugrep}/bin/grep -q "ID=aosc" /etc/os-release; then + app_args+=(--no-spark) +fi + +exec ${electron}/bin/electron "\''${electron_args[@]}" "$appDir" "\''${app_args[@]}" "\$@" +EOF + chmod +x "$out/bin/spark-store" + + patchShebangs "$appDir/extras" + + runHook postInstall + ''; + + postFixup = '' + appDir="$out/share/spark-store" + + wrapProgram "$appDir/extras/shell-caller.sh" \ + --prefix PATH : ${ + lib.makeBinPath [ + aria2 + bash + coreutils + which + xdg-utils + ] + } + + substituteInPlace "$appDir/extras/.shell-caller.sh-wrapped" \ + --replace-fail "#!/bin/bash" "#!${bash}/bin/bash" + ''; + + meta = { + description = "Client for Spark App Store"; + homepage = "https://spark-app.store"; + license = lib.licenses.gpl3Only; + mainProgram = "spark-store"; + platforms = lib.platforms.linux; + }; +} From 23b09ca863af0d63f9341ddb2cf45ad2e4e636f4 Mon Sep 17 00:00:00 2001 From: SunnyPai0413 Date: Wed, 17 Jun 2026 12:59:11 +0800 Subject: [PATCH 2/3] update readme.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 19d04b3b..67fb7ad8 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Linux 应用的数量相对有限,Wine 软件的可获取性也颇为困难。 **当前支持的 Linux 发行版包括(但不限于):** -- **amd64 架构:** Debian 10+ / Ubuntu 22.04+ / Arch Linux / Fedora / deepin / UOS / 银河麒麟 +- **amd64 架构:** Debian 10+ / Ubuntu 22.04+ / Arch Linux / Fedora / deepin / UOS / 银河麒麟 / NixOS - **arm64 架构:** Debian 10+ / Ubuntu 22.04+ / Arch Linux / deepin / UOS / 银河麒麟 - **loong64 架构:** deepin 23/25 From 399b59dae819fca766c7bd8f6ed4e82d832a8e3e Mon Sep 17 00:00:00 2001 From: SunnyPai0413 Date: Wed, 17 Jun 2026 13:30:38 +0800 Subject: [PATCH 3/3] fix pkexec lookup for privilege escalation --- electron/main/backend/install-manager.ts | 32 +-------------- electron/main/backend/shared-installer.ts | 32 +-------------- electron/main/backend/superuser.ts | 49 +++++++++++++++++++++++ 3 files changed, 51 insertions(+), 62 deletions(-) create mode 100644 electron/main/backend/superuser.ts diff --git a/electron/main/backend/install-manager.ts b/electron/main/backend/install-manager.ts index 5ddc05f0..de169d2e 100644 --- a/electron/main/backend/install-manager.ts +++ b/electron/main/backend/install-manager.ts @@ -6,6 +6,7 @@ import pino from "pino"; import { ChannelPayload } from "../../typedefinition"; import axios from "axios"; +import { findExecutable, SUPER_USER_COMMAND_CANDIDATES } from "./superuser"; const logger = pino({ name: "install-manager" }); @@ -43,42 +44,11 @@ type InstallTask = { }; const SHELL_CALLER_PATH = "/opt/spark-store/extras/shell-caller.sh"; -const SUPER_USER_COMMAND_CANDIDATES = [ - "/usr/bin/pkexec", - "/run/wrappers/bin/pkexec", - "pkexec", -]; export const tasks = new Map(); let idle = true; // Indicates if the installation manager is idle -const findExecutable = async (command: string): Promise => { - if (path.isAbsolute(command)) { - try { - await fs.promises.access(command, fs.constants.X_OK); - return command; - } catch { - return ""; - } - } - - return await new Promise((resolve) => { - const child = spawn("which", [command]); - let stdout = ""; - - child.stdout?.on("data", (data) => { - stdout += data.toString(); - }); - child.on("close", (code) => { - resolve(code === 0 ? stdout.trim() : ""); - }); - child.on("error", () => { - resolve(""); - }); - }); -}; - export const checkSuperUserCommand = async (): Promise => { if (process.getuid?.() === 0) return ""; diff --git a/electron/main/backend/shared-installer.ts b/electron/main/backend/shared-installer.ts index 806d0fa4..99d80353 100644 --- a/electron/main/backend/shared-installer.ts +++ b/electron/main/backend/shared-installer.ts @@ -8,15 +8,11 @@ import * as fs from "node:fs"; import * as path from "node:path"; import axios from "axios"; import pino from "pino"; +import { findExecutable, SUPER_USER_COMMAND_CANDIDATES } from "./superuser"; const logger = pino({ name: "shared-installer" }); export const SHELL_CALLER_PATH = "/opt/spark-store/extras/shell-caller.sh"; -const SUPER_USER_COMMAND_CANDIDATES = [ - "/usr/bin/pkexec", - "/run/wrappers/bin/pkexec", - "pkexec", -]; export interface DownloadOptions { pkgname: string; @@ -363,29 +359,3 @@ export const checkSuperUserCommand = async (): Promise => { logger.error("没有找到提升权限的命令 pkexec!"); return ""; }; - -const findExecutable = async (command: string): Promise => { - if (path.isAbsolute(command)) { - try { - await fs.promises.access(command, fs.constants.X_OK); - return command; - } catch { - return ""; - } - } - - return await new Promise((resolve) => { - const child = spawn("which", [command]); - let stdout = ""; - - child.stdout?.on("data", (data) => { - stdout += data.toString(); - }); - child.on("close", (code) => { - resolve(code === 0 ? stdout.trim() : ""); - }); - child.on("error", () => { - resolve(""); - }); - }); -}; diff --git a/electron/main/backend/superuser.ts b/electron/main/backend/superuser.ts new file mode 100644 index 00000000..98046338 --- /dev/null +++ b/electron/main/backend/superuser.ts @@ -0,0 +1,49 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +export const SUPER_USER_COMMAND_CANDIDATES = [ + "/usr/bin/pkexec", + "/run/wrappers/bin/pkexec", +]; + +const WHICH_TIMEOUT_MS = 5000; + +export const findExecutable = async (command: string): Promise => { + if (path.isAbsolute(command)) { + try { + await fs.promises.access(command, fs.constants.X_OK); + return command; + } catch { + return ""; + } + } + + return await new Promise((resolve) => { + const child = spawn("which", [command]); + let stdout = ""; + let settled = false; + + const timer = setTimeout(() => { + child.kill(); + finish(""); + }, WHICH_TIMEOUT_MS); + + function finish(result: string) { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(result); + } + + child.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + child.on("close", (code) => { + finish(code === 0 ? stdout.trim() : ""); + }); + child.on("error", () => { + finish(""); + }); + }); +};