diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2c43d434f..3d72521bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,14 @@ Thank you for considering a contribution to Arkloop. This document covers the pr - Docker and Docker Compose - PostgreSQL 16+ (or use `docker compose up postgres`) +Alternatively, if you use Nix with flakes enabled, enter the pinned development shell: + +```bash +nix develop +``` + +The flake provides Go 1.26, Node.js, Corepack-managed pnpm, Docker Compose, PostgreSQL/Redis clients, and the local CI helper dependencies. Docker still needs a running host daemon. + ### Local Development Setup ```bash diff --git a/README.md b/README.md index ea73439c8..fdc991277 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,15 @@ Homebrew installs the Arkloop CLI only: brew install qqqqqf-q/arkloop/arkloop && ark web ``` +### Desktop via Nix + +Linux users with flakes enabled can run or install the desktop app from the flake: + +```bash +nix run github:qqqqqf-q/Arkloop +nix profile install github:qqqqqf-q/Arkloop +``` + For a headless Linux machine, use one command: ```bash diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..e6a89c438 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1781268102, + "narHash": "sha256-Zn5KTggEmUB3lXn/ccERNcBdddE6IaOFber9dWViWDg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "49a4bd0573c376468dd7996ddb6f9fa31d8c4d97", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..ad8875d5b --- /dev/null +++ b/flake.nix @@ -0,0 +1,407 @@ +{ + description = "Arkloop desktop application and development environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + }; + + outputs = + { self, nixpkgs, ... }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + linuxSystems = [ + "x86_64-linux" + "aarch64-linux" + ]; + + forAllSystems = + f: + nixpkgs.lib.genAttrs systems ( + system: + f ( + import nixpkgs { + inherit system; + } + ) + ); + + forLinuxSystems = + f: + nixpkgs.lib.genAttrs linuxSystems ( + system: + f ( + import nixpkgs { + inherit system; + } + ) + ); + + desktopVersion = (builtins.fromJSON (builtins.readFile ./src/apps/desktop/package.json)).version; + + sourceFilter = + lib: path: type: + let + root = toString ./.; + rel = lib.removePrefix "${root}/" (toString path); + excluded = [ + ".cache" + ".data" + ".env" + ".envrc" + ".git" + ".gstack" + ".mypy_cache" + ".pytest_cache" + ".ruff_cache" + ".vscode" + "AGENTS.md" + "CLAUDE.md" + "data" + "mcp.config.json" + "node_modules" + "result" + "result-arkloop-desktop" + "config/openviking/ov.conf" + "src/apps/desktop/cli-bin" + "src/apps/desktop/dist" + "src/apps/desktop/release" + "src/apps/desktop/sidecar-bin" + "src/apps/web/dist" + ]; + in + !(lib.any (prefix: rel == prefix || lib.hasPrefix "${prefix}/" rel) excluded) + && !(lib.hasPrefix ".env." rel); + + cleanSource = + pkgs: + pkgs.lib.cleanSourceWith { + src = ./.; + filter = sourceFilter pkgs.lib; + }; + + electronArchFor = pkgs: if pkgs.stdenv.hostPlatform.isAarch64 then "arm64" else "x64"; + + buildGo126 = pkgs: pkgs.buildGoModule.override { go = pkgs.go_1_26; }; + + goBinaries = + pkgs: + (buildGo126 pkgs) { + pname = "arkloop-desktop-go-binaries"; + version = desktopVersion; + src = cleanSource pkgs; + modRoot = "./"; + subPackages = [ + "src/services/activity-record/cmd/activity-record" + "src/services/cli/cmd/ark" + "src/services/desktop/cmd/desktop" + ]; + tags = [ "desktop" ]; + ldflags = [ + "-s" + "-w" + "-X main.version=v${desktopVersion}" + ]; + vendorHash = "sha256-HSP4IMyafSLyMI7bt7VKqOU2EHDkB/PNdELhoSSfjUI="; + env.CGO_ENABLED = "0"; + doCheck = false; + modBuildPhase = '' + runHook preBuild + go work vendor + mkdir -p vendor + runHook postBuild + ''; + }; + + arkloopDesktop = + pkgs: + let + lib = pkgs.lib; + pnpm = pkgs.pnpm_10_29_2; + electronArch = electronArchFor pkgs; + goBin = goBinaries pkgs; + gpuLibraryPath = lib.makeLibraryPath [ + pkgs.libglvnd + pkgs.libva + pkgs.mesa + pkgs.vulkan-loader + ]; + runtimePath = lib.makeBinPath [ + pkgs.bash + pkgs.coreutils + pkgs.docker-client + pkgs.docker-compose + pkgs.git + pkgs.openssl + pkgs.xdg-utils + ]; + in + pkgs.stdenv.mkDerivation (finalAttrs: { + pname = "arkloop-desktop"; + version = desktopVersion; + src = cleanSource pkgs; + + pnpmDeps = pkgs.fetchPnpmDeps { + inherit (finalAttrs) pname version src; + pnpm = pnpm; + fetcherVersion = 4; + hash = "sha256-ycM0fYo1O3exXS3jMgarfXei4mMWEFx7nhw1X6UOR5Y="; + }; + + nativeBuildInputs = [ + pkgs.go_1_26 + pkgs.jq + pkgs.makeWrapper + pkgs.nodejs_24 + pkgs.pnpmConfigHook + pkgs.wrapGAppsHook3 + pnpm + ]; + + buildInputs = [ + pkgs.glib + pkgs.gsettings-desktop-schemas + pkgs.gtk3 + pkgs.gtk4 + ]; + + dontWrapGApps = true; + + env = { + ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; + GOTOOLCHAIN = "local"; + }; + + postPatch = '' + substituteInPlace package.json src/apps/desktop/package.json \ + --replace-fail '"packageManager": "pnpm@10.29.3"' '"packageManager": "pnpm@${pnpm.version}"' + ''; + + preBuild = '' + export HOME="$TMPDIR/home" + export XDG_CACHE_HOME="$TMPDIR/xdg-cache" + export ELECTRON_BUILDER_CACHE="$TMPDIR/electron-builder-cache" + export npm_config_fund=false + export npm_config_update_notifier=false + export pnpm_config_manage_package_manager_versions=false + export ARKLOOP_RELEASE_VERSION="v${desktopVersion}" + + mkdir -p "$HOME" "$XDG_CACHE_HOME" "$ELECTRON_BUILDER_CACHE" "$TMPDIR/nix-bin" + cat > "$TMPDIR/nix-bin/pnpm" <<'EOF' + #!${pkgs.runtimeShell} + exec ${pnpm}/bin/pnpm --config.manage-package-manager-versions=false "$@" + EOF + chmod +x "$TMPDIR/nix-bin/pnpm" + export PATH="$TMPDIR/nix-bin:$PATH" + + mkdir -p src/apps/desktop/sidecar-bin src/apps/desktop/cli-bin + cp ${goBin}/bin/desktop src/apps/desktop/sidecar-bin/desktop-linux-${electronArch} + cp ${goBin}/bin/ark src/apps/desktop/cli-bin/ark-linux-${electronArch} + ''; + + buildPhase = '' + runHook preBuild + + pnpm --config.manage-package-manager-versions=false --filter @arkloop/desktop run build:web + pnpm --config.manage-package-manager-versions=false --filter @arkloop/desktop run build:electron + pnpm --config.manage-package-manager-versions=false --dir src/apps/desktop exec electron-builder \ + --linux dir \ + -c.electronDist=${pkgs.electron_41.dist} \ + -c.electronVersion=${pkgs.electron_41.version} \ + -c.extraMetadata.version=${desktopVersion} + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + release_dir="src/apps/desktop/release/linux-unpacked" + if [ ! -d "$release_dir" ]; then + echo "missing electron-builder output: $release_dir" >&2 + exit 1 + fi + + if [ ! -d "$release_dir/resources" ]; then + echo "missing electron-builder resources: $release_dir/resources" >&2 + exit 1 + fi + if [ ! -f "$release_dir/resources/app.asar" ]; then + echo "missing electron app archive: $release_dir/resources/app.asar" >&2 + exit 1 + fi + + resources_dir="$out/share/arkloop/resources" + mkdir -p "$resources_dir" "$out/bin" "$out/share/applications" "$out/share/icons/hicolor/512x512/apps" + cp -R "$release_dir/resources/." "$resources_dir/" + + mkdir -p "$resources_dir/activity-record/bin" + cp ${goBin}/bin/activity-record "$resources_dir/activity-record/bin/activity-record" + + mkdir -p "$out/libexec/arkloop" + cat > "$out/libexec/arkloop/arkloop" < "$out/share/applications/arkloop.desktop" < { const devMockPhase = readDevMockPhase() if (devMockPhase) return buildDevMockState(devMockPhase) + if (isUpdaterDisabled()) { + return { + supported: false, + phase: 'unsupported', + currentVersion: app.getVersion(), + latestVersion: null, + progressPercent: 0, + error: null, + } + } return { supported: app.isPackaged, phase: app.isPackaged ? 'idle' : 'unsupported', @@ -111,7 +126,7 @@ export async function checkForAppUpdates(): Promise { return getAppUpdaterState() } - if (!app.isPackaged) { + if (isUpdaterDisabled() || !app.isPackaged) { patchState({ phase: 'unsupported', latestVersion: null, progressPercent: 0, error: null }) return getAppUpdaterState() } @@ -135,7 +150,7 @@ export async function downloadAppUpdate(): Promise { return getAppUpdaterState() } - if (!app.isPackaged) { + if (isUpdaterDisabled() || !app.isPackaged) { patchState({ phase: 'unsupported', latestVersion: null, progressPercent: 0, error: null }) return getAppUpdaterState() } @@ -159,7 +174,7 @@ export function installAppUpdate(): void { return } - if (!app.isPackaged) { + if (isUpdaterDisabled() || !app.isPackaged) { patchState({ phase: 'unsupported', latestVersion: null, progressPercent: 0, error: null }) return } @@ -183,7 +198,7 @@ export function setupAppUpdater( } initialized = true - if (!app.isPackaged) { + if (isUpdaterDisabled() || !app.isPackaged) { patchState({}) if (readDevMockPhase() && options.autoCheck) { void checkForAppUpdates().catch(() => {}) diff --git a/src/apps/desktop/src/main/cli-tool.ts b/src/apps/desktop/src/main/cli-tool.ts index 7b17218c6..a6243bcd5 100644 --- a/src/apps/desktop/src/main/cli-tool.ts +++ b/src/apps/desktop/src/main/cli-tool.ts @@ -3,7 +3,7 @@ import * as fs from 'fs' import * as os from 'os' import * as path from 'path' import { execFileSync } from 'child_process' -import { loadConfig, saveConfig } from './config' +import { loadConfig, resourcePath, saveConfig } from './config' export type CommandLineToolStatus = { available: boolean @@ -36,7 +36,7 @@ function fileExists(pathname: string): boolean { } function bundledCliPath(): string { - return path.join(process.resourcesPath, 'cli', cliBinaryName()) + return resourcePath('cli', cliBinaryName()) } function bundledWebRoot(sourcePath: string): string { @@ -161,6 +161,7 @@ export async function installCommandLineTool(): Promise { function shouldPromptForCommandLineTool(): boolean { if (!app.isPackaged) return false + if (process.env.ARKLOOP_NIX_PACKAGE === '1') return false const config = loadConfig() if (config.desktop.commandLineToolPromptDisabled) return false const status = getCommandLineToolStatus() diff --git a/src/apps/desktop/src/main/config.ts b/src/apps/desktop/src/main/config.ts index 4ed2e2460..369e3f466 100644 --- a/src/apps/desktop/src/main/config.ts +++ b/src/apps/desktop/src/main/config.ts @@ -31,6 +31,14 @@ const VERSIONS_FILE = path.join(CONFIG_DIR, 'versions.json') const LEGACY_SIDECAR_VERSION_FILE = path.join(CONFIG_DIR, 'bin', 'sidecar.version.json') const LEGACY_OPENCLI_VERSION_FILE = path.join(CONFIG_DIR, 'bin', 'opencli.version.json') +export function getResourcesPath(): string { + return process.env.ARKLOOP_RESOURCES_PATH || process.resourcesPath +} + +export function resourcePath(...segments: string[]): string { + return path.join(getResourcesPath(), ...segments) +} + export type VersionsState = { sidecar?: { version: string; updated_at: string } openviking?: { version: string; updated_at: string } diff --git a/src/apps/desktop/src/main/index.ts b/src/apps/desktop/src/main/index.ts index 30e6e73d9..43aa5d60a 100644 --- a/src/apps/desktop/src/main/index.ts +++ b/src/apps/desktop/src/main/index.ts @@ -2,7 +2,7 @@ import { app, BrowserWindow, Menu, nativeImage, powerSaveBlocker, session, shell import * as fs from 'fs' import * as os from 'os' import * as path from 'path' -import { loadConfig, normalizeConfig, saveConfig } from './config' +import { loadConfig, normalizeConfig, resourcePath, saveConfig } from './config' import { startSidecar, stopSidecar, @@ -69,19 +69,19 @@ function getAppIconPath(): string { ? ( process.platform === 'darwin' ? [ - path.join(process.resourcesPath, 'icon.icns'), - path.join(process.resourcesPath, 'app.asar', 'resources', 'icon.png'), + resourcePath('icon.icns'), + resourcePath('app.asar', 'resources', 'icon.png'), path.join(app.getAppPath(), 'resources', 'icon.icns'), ] : process.platform === 'win32' ? [ - path.join(process.resourcesPath, 'icon.ico'), - path.join(process.resourcesPath, 'app.asar', 'resources', 'icon.ico'), + resourcePath('icon.ico'), + resourcePath('app.asar', 'resources', 'icon.ico'), path.join(app.getAppPath(), 'resources', 'icon.ico'), ] : [ - path.join(process.resourcesPath, 'icon.png'), - path.join(process.resourcesPath, 'app.asar', 'resources', 'icon.png'), + resourcePath('icon.png'), + resourcePath('app.asar', 'resources', 'icon.png'), path.join(app.getAppPath(), 'resources', 'icon.png'), ] ) @@ -457,7 +457,7 @@ function loadContent(win: BrowserWindow): void { win.webContents.openDevTools({ mode: 'detach' }) } else if (app.isPackaged) { // 生产打包模式 - const rendererPath = path.join(process.resourcesPath, 'renderer', 'index.html') + const rendererPath = resourcePath('renderer', 'index.html') win.loadFile(rendererPath) } else { // 开发模式但非 ELECTRON_DEV(直接 build 后测试) @@ -466,6 +466,25 @@ function loadContent(win: BrowserWindow): void { } } +async function logGpuInfoIfRequested(): Promise { + if (process.env.ARKLOOP_LOG_GPU !== '1') return + try { + console.info('[desktop] gpu command line', { + disableGpu: app.commandLine.hasSwitch('disable-gpu'), + disableGpuSandbox: app.commandLine.hasSwitch('disable-gpu-sandbox'), + ignoreBlocklist: app.commandLine.hasSwitch('ignore-gpu-blocklist'), + ozonePlatform: app.commandLine.getSwitchValue('ozone-platform'), + useGl: app.commandLine.getSwitchValue('use-gl'), + useAngle: app.commandLine.getSwitchValue('use-angle'), + disableFeatures: app.commandLine.getSwitchValue('disable-features'), + }) + console.info('[desktop] gpu feature status', app.getGPUFeatureStatus()) + console.info('[desktop] gpu info', await app.getGPUInfo('complete')) + } catch (error) { + console.warn('[desktop] gpu info unavailable', { error }) + } +} + let isQuitting = false let shutdownInProgress = false let powerSaveBlockerId: number | null = null @@ -545,6 +564,9 @@ if (!hasSingleInstanceLock) { // 先创建窗口,让用户立即看到 LoadingPage mainWindow = createWindow() + setTimeout(() => { + void logGpuInfoIfRequested() + }, 1000) const pushEmbeddedStateToRendererOnce = (() => { let sent = false diff --git a/src/apps/desktop/src/main/ipc.ts b/src/apps/desktop/src/main/ipc.ts index c0bf4076c..233d73dff 100644 --- a/src/apps/desktop/src/main/ipc.ts +++ b/src/apps/desktop/src/main/ipc.ts @@ -3,7 +3,7 @@ import http from 'http' import os from 'os' import { DatabaseSync } from 'node:sqlite' import path from 'path' -import { loadConfig, saveConfig, getConfigPath } from './config' +import { loadConfig, resourcePath, saveConfig, getConfigPath } from './config' import { getSidecarStatus, getSidecarRuntime, @@ -1083,19 +1083,19 @@ function getDesktopIconDataUrl(): string | null { ? ( process.platform === 'darwin' ? [ - path.join(process.resourcesPath, 'app.asar', 'resources', 'icon.png'), - path.join(process.resourcesPath, 'icon.png'), + resourcePath('app.asar', 'resources', 'icon.png'), + resourcePath('icon.png'), ] : process.platform === 'win32' ? [ - path.join(process.resourcesPath, 'app.asar', 'resources', 'icon.png'), - path.join(process.resourcesPath, 'icon.png'), - path.join(process.resourcesPath, 'icon.ico'), - path.join(process.resourcesPath, 'app.asar', 'resources', 'icon.ico'), + resourcePath('app.asar', 'resources', 'icon.png'), + resourcePath('icon.png'), + resourcePath('icon.ico'), + resourcePath('app.asar', 'resources', 'icon.ico'), ] : [ - path.join(process.resourcesPath, 'app.asar', 'resources', 'icon.png'), - path.join(process.resourcesPath, 'icon.png'), + resourcePath('app.asar', 'resources', 'icon.png'), + resourcePath('icon.png'), ] ) : [ diff --git a/src/apps/desktop/src/main/sidecar.ts b/src/apps/desktop/src/main/sidecar.ts index 62200a915..5292459ec 100644 --- a/src/apps/desktop/src/main/sidecar.ts +++ b/src/apps/desktop/src/main/sidecar.ts @@ -10,6 +10,7 @@ import { app } from 'electron' import type { LocalPortMode, MemoryConfig, NetworkConfig } from './types' import { appendSidecarLog, getDesktopLogPaths } from './logging' import { getBrowserSearchBaseUrl } from './browser-search' +import { resourcePath } from './config' export type SidecarStatus = 'stopped' | 'starting' | 'running' | 'crashed' @@ -147,7 +148,7 @@ export function getSidecarPath(): string { function getBundledSidecarPath(): string { const bundledName = process.platform === 'win32' ? 'desktop.exe' : 'desktop' - return path.join(process.resourcesPath, 'sidecar', bundledName) + return resourcePath('sidecar', bundledName) } function getDevBuiltSidecarPath(): string { @@ -473,7 +474,7 @@ function resolveBinaryPath(): string { function resolveBundledProjectDir(): string | null { if (!app.isPackaged) return null - const candidate = path.join(process.resourcesPath, 'arkloop-project') + const candidate = resourcePath('arkloop-project') if (!fs.existsSync(path.join(candidate, 'compose.yaml'))) return null return ensureWritableBundledProjectDir(candidate) } diff --git a/src/apps/desktop/src/main/tray.ts b/src/apps/desktop/src/main/tray.ts index 65c7641b0..008782886 100644 --- a/src/apps/desktop/src/main/tray.ts +++ b/src/apps/desktop/src/main/tray.ts @@ -1,6 +1,7 @@ import { Tray, Menu, nativeImage, nativeTheme, app, BrowserWindow, globalShortcut } from 'electron' import * as fs from 'fs' import * as path from 'path' +import { resourcePath } from './config' let tray: Tray | null = null type ShowWindow = () => void @@ -8,8 +9,8 @@ type ShowWindow = () => void function resolveResource(name: string): string { const candidates = app.isPackaged ? [ - path.join(process.resourcesPath, name), - path.join(process.resourcesPath, 'app.asar', 'resources', name), + resourcePath(name), + resourcePath('app.asar', 'resources', name), path.join(app.getAppPath(), 'resources', name), ] : [