diff --git a/.changeset/linux-port-initial.md b/.changeset/linux-port-initial.md new file mode 100644 index 00000000..be88116e --- /dev/null +++ b/.changeset/linux-port-initial.md @@ -0,0 +1,5 @@ +--- +"@1git/gpuix": minor +--- + +Add an initial Linux port: build the patched GPUIX addon on Linux, open browsers and files with xdg-open, copy with wl-copy/xclip/xsel, prompt SSH host trust and passwords with zenity, and package a self-contained Linux tarball with `bun run package:linux`. Five macOS-only native methods are moved to a macOS-only impl block on Linux so the addon compiles there; on Linux those features fall back through the existing renderer guards. macOS behaviour is unchanged. diff --git a/LINUX-PORT.md b/LINUX-PORT.md new file mode 100644 index 00000000..8e83dbb0 --- /dev/null +++ b/LINUX-PORT.md @@ -0,0 +1,200 @@ +# Linux port (work in progress) + +Status: first Linux window achieved on branch `linux-port`. +The native addon builds on Linux and `bun run dev:app` opens a real +1git window (see "Verified"). This is still not a finished product: +the window was verified under Xvfb on Ubuntu 24.04, not yet on a real +GNOME X11/Wayland desktop, and the TODO list below still stands. + +## Why this port is plausible + +The hard part is not the UI engine. 1git is TypeScript + React 19 on +GPUIX (Zed's GPUI bindings), and GPUI itself already has Linux X11 and +Wayland backends. Evidence in this repo: + +- `bun.lock` already pins `@gpuix/native-linux-x64-gnu 0.10.0` as an + optional dependency of `@gpuix/native 0.10.0`. +- GPUIX upstream ships Linux examples and AppImage packaging via + `cargo-packager`. +- The SSH remote helper already builds static Linux glibc/musl binaries + (`apps/gpuix/native/build-remote-helpers.ts`), and the `packages/git` + layer is plain Git subprocess work, not macOS code. +- Session and avatar-cache paths already fall through to + `XDG_STATE_HOME` / `XDG_CACHE_HOME` on non-macOS platforms. + +The blockers were 1git's own macOS gates, not the engine: + +- `apps/gpuix/native/build.ts` threw unless `darwin/arm64`. +- `src/platform/browser.ts` / `clipboard.ts` threw unless macOS + (`/usr/bin/open`, `pbcopy`). +- File actions, editor launching and SSH prompts hard-coded + `/usr/bin/open`, `/usr/bin/open -a`, and `osascript` dialogs. +- Packaging, signing and updates were macOS-only (`.app`, codesign, + Sparkle, Keychain copy in UI strings). + +## What this branch changes + +- `native/build.ts`: allow Linux x64/arm64; copy `libgpuix_native.so` + instead of `.dylib`. The `*-platform` patches that target + `crates/gpui_macos` still apply (those files exist in the checkout) + but are not compiled into the Linux addon, so the patch stack is not + forked. +- `native/package-linux.ts` + `package:linux` scripts: a self-contained + tarball (`build/linux/1git-linux-.tar.gz`) reusing the macOS + bundle's relative layout (`bin/1git`, `Frameworks/gpuix-native.node`, + `Resources/app.js`, `Resources/remote-helpers`), so + `native/packaged-main.ts` works unchanged. No signing, no Sparkle; + `AppUpdater.start()` already no-ops off macOS. +- `native/package-resources.ts`: `copyImageRuntimeLinux()` copies the + glibc sharp runtime (`@img/sharp-linux-`, + `@img/sharp-libvips-linux-`), not the darwin one and not musl. +- `src/platform/browser.ts`: `xdg-open` on Linux. +- `src/platform/clipboard.ts`: `wl-copy`, then `xclip`, then `xsel`. + Note: `Bun.spawn` throws when a binary is missing instead of exiting + non-zero, so each candidate is wrapped in try/catch (found by a + smoke test, see below). +- `src/feature/repository-workspace/lib/repository-file-actions.ts`: + open/reveal via `xdg-open` (reveal opens the parent folder; xdg-open + has no `-R` equivalent). +- `src/feature/machines/ssh-connection.ts`: SSH host-trust/password + prompts via `zenity` on Linux instead of `osascript`. +- `src/feature/editor-settings/external-editors.ts`: on Linux, an + explicit executable path is run directly, otherwise `xdg-open`. + Editor catalog discovery is still macOS-only (see TODO). +- `src/window-options.ts`: no blurred background or traffic lights on + Linux; the compositor's native decorations are used. +- `native/macos-napi-methods-linux.patch` (Linux only, applied last): + moves five macOS-only `#[napi]` methods (`get_font_families`, + `get_system_appearance`, `set_window_appearance`, `set_close_blocked`, + `take_close_requested`) out of the main `GpuixRenderer` impl into a + macOS-only impl block. napi-derive registers `cfg`'d methods from + the main impl even on Linux, where their callbacks do not exist, + so the addon failed to compile without this move (five `E0425` + errors). A `cfg_attr` workaround was tried first and did not work. + On Linux those five methods are absent, and the TS callers already + guard them with `"method" in renderer` (system appearance falls back + to light, close protection and font list are unavailable, window + appearance is skipped). macOS keeps the original patch stack. +- Changeset: `.changeset/linux-port-initial.md` (`@1git/gpuix`, minor). + +## Ubuntu 20.04: the floor, and the build rule + +Target: Ubuntu 20.04 LTS x64, glibc 2.31, Git 2.25, X11 by default +(Wayland session optional). + +**Build on 20.04 to ship for 20.04.** glibc is forward-compatible +only: a Rust/Bun binary built on Ubuntu 24.04 (glibc 2.39) will refuse +to run on 20.04. Do the release build in a 20.04 container or on a +20.04 host, and verify with `ldd` that no `GLIBC_2.32+` symbol is +required. This VM (Ubuntu 24.04) is therefore fine for development +checks, but not for a 20.04 release binary. + +Other 20.04 notes to verify on a real 20.04 machine (not assumed here): + +- Bun 1.4.2: its Linux binary is known to require at least + GLIBC_2.29, which 2.31 satisfies, but the exact 1.4.2 binary must + still be run on 20.04 before claiming support. +- Sharp 0.35.4 linux-x64 (glibc) prebuilt runtime: check it loads on + 2.31; do not substitute the linuxmusl build on Ubuntu. +- Git 2.25 covers `switch`/`restore`/worktrees; audit any newer Git + flag in `packages/git` before release. +- `curl --unix-socket` (used by the SSH askpass script) exists in + 20.04's curl 7.68. `zenity` and `xdg-utils` are the new runtime + dependencies to document, not bundle. + +Runtime dependencies for the tarball (to install, not bundle): +`git`, `xdg-utils`, `zenity`, `wl-clipboard` (Wayland) or `xclip` +(X11), plus the desktop's Wayland/X11 libraries and a Vulkan driver +(or Mesa lavapipe for software rendering). + +## Verified in this session (Ubuntu 24.04, Xvfb, Rust 1.97.1) + +- `bun install --frozen-lockfile`: ok (616 packages). +- `bun run build` (turbo, all 5 tasks): ok. +- `bun run --cwd apps/gpuix check-types`: ok after the workspace build. +- `bun x oxfmt --check` and `bun x oxlint` on the changed files: clean. +- Shim smoke test (Bun, no display): invalid URL still rejected; + clipboard on a machine with no wl-copy/xclip/xsel now fails with the + intended "install wl-clipboard or xclip" message instead of a raw + spawn error. +- **Native build:** `bun run --cwd apps/gpuix build:native` succeeds + and writes a 51 MB `apps/gpuix/.cache/native/gpuix-native.node`. + System deps needed: Rust 1.97.1 (rustup, pinned by GPUIX), + `libx11-xcb-dev`, `libxkbcommon-x11-dev`, `libxcb*-dev`, + `libwayland-dev`, `libfontconfig-dev`, `libvulkan1`, + `mesa-vulkan-drivers`. Two environment traps, both hit in this + session and worth knowing: (a) this VM's egress proxy credential + rotates on every new exec session, so a `cargo fetch` that shares + its session with later commands stalls on downloads; fetch once in + a single session, then build with `CARGO_NET_OFFLINE=true`. + (b) VM whole-machine replacements wipe the apt layer (this session + lost `mesa-vulkan-drivers` and the `-dev` symlinks mid-build, which + surfaced as `rust-lld: unable to find library -lxkbcommon-x11` and + as a Vulkan surface failure); reinstall the deps after a + replacement, before rebuilding. +- **First window:** `DISPLAY=:99 bun --preload ./native/preload.ts + --hot src/main.tsx` under Xvfb (1920x1080) opens a 1280x820 window + titled "1git" with the real onboarding flow: "Welcome to 1git", + Light/Dark/Auto cards, step 1 of 4, "Skip setup" / "Continue". + Rendering uses llvmpipe (Mesa software Vulkan, no GPU in this VM). + Non-fatal log line under Xvfb: `Found no xinput mouse pointers` + (Xvfb has no input devices; a real desktop has them). + Screenshot: `~/workspace/your_files/1git-linux-first-window.png`. +- **Packaged tarball (2026-10-26):** `bun run --cwd apps/gpuix + package:linux` produces `build/linux/1git-linux-x64.tar.gz` + (76 MB, sha256 `10a7ceb4…bfb`). Contents: `bin/1git` (Bun + compiled, 105 MB), `Frameworks/gpuix-native.node` (51 MB), + `Resources/app.js`, sharp Linux runtime, and Linux remote + helpers only. Two packaging fixes landed with it: + `package-linux.ts` now builds/copies only `linux-*` remote + helpers (the darwin helpers cannot be cross-built from Linux), + and the tarball README states the real glibc floor (see below). + The extracted copy was smoke-tested under Xvfb and opens the + same Welcome screen as the dev build. +- **glibc floor, verified by symbol scan:** `gpuix-native.node` + requires up to `GLIBC_2.39`, so this development tarball runs on + Ubuntu 24.04+ only, **not** on 20.04 (glibc 2.31), despite the + original 20.04 target. A 20.04 tarball must be built on 20.04 + itself; that is still pending and is the main blocker for the + stated minimum version. + +## Not yet verified / TODO + +1. **Real desktop run.** Xvfb verification done (dev build and + packaged tarball alike). User-verified 2026-10-26 on a real + Ubuntu 24.04 machine with the packaged tarball: window opens, + mouse input normal, window resize works. Still untested on a + real machine: Wayland vs X11 session type (not recorded), + keyboard shortcuts (see Cmd -> Ctrl below), and HiDPI. +2. **Ubuntu 20.04 build.** Rebuild `build:native` and + `package:linux` on a 20.04 host/container and verify the + tarball's glibc ceiling is 2.31 before claiming 20.04 support. +3. **Cmd -> Ctrl sweep.** Many handlers already accept `cmd || ctrl`, + but pure `cmd` checks (quick switcher, Cmd-click, settings + shortcuts) will not fire on Linux, where GPUI reports the Super key + as `cmd`. Map them to Ctrl on Linux, file by file, with tests. +4. **Editor catalog on Linux.** Discover `code`, `cursor`, `zed`, + `subl` from PATH and `.desktop` files; the current Linux branch + only handles an explicit executable path or the default handler. +5. **File-menu labels and icons.** "Reveal in Finder" and the + Finder.app icon path are still macOS strings; rename to the file + manager on Linux and drop the Finder icon lookup. +6. **Credentials.** Account secrets go through GPUI's + `read/write_credentials` (added by `accounts.patch`). Verify it + against GNOME Keyring and KWallet on Linux, and decide the + fallback for headless/no-keyring machines. Do not silently fall + back to plaintext. +7. **Updater.** Sparkle is macOS-only. Either ship Linux with no + auto-updater (v1, documented in the tarball README) or adopt + GPUIX's GitHub-Releases `checkUpdate` flow. +8. **Packaging.** The tarball is a stopgap. A `.deb` (or AppImage via + `cargo-packager`, as GPUIX upstream does) with a real `.desktop` + file and icon is the proper distribution format. +9. **Tests.** The vibrancy, traffic-light, signing, notarization and + macOS launch integration suites are macOS-only; they need Linux + equivalents or explicit skips, not silent passes. +10. **Push status.** Pushed 2026-10-26 to the user's fork + `wuqingwei/1git`, branch `linux-port` (3 commits: scaffolding, + native build + first window, Linux tarball packaging). Upstream + `origin` (`1bye/1git`) was not touched and no PR has been opened. + Remote `wuqingwei` in this worktree points at the fork. diff --git a/apps/gpuix/native/build.ts b/apps/gpuix/native/build.ts index 917ca02d..7896e5c4 100644 --- a/apps/gpuix/native/build.ts +++ b/apps/gpuix/native/build.ts @@ -53,14 +53,24 @@ import { menuPlacementPlatformPatchPath, systemAppearancePatchPath, systemAppearancePlatformPatchPath, + macosNapiMethodsPatchPath, } from "./config"; -if (process.platform !== "darwin" || process.arch !== "arm64") { +const supportedNativeBuild = + (process.platform === "darwin" && process.arch === "arm64") || + (process.platform === "linux" && ["x64", "arm64"].includes(process.arch)); + +if (!supportedNativeBuild) { throw new Error( - "The local GPUIX build currently supports Apple Silicon macOS only." + "The local GPUIX build currently supports Apple Silicon macOS and Linux x64/arm64 only." ); } +// The *-platform patches that target crates/gpui_macos still apply cleanly +// on Linux (those source files exist in the checkout); they are simply not +// compiled into the Linux addon. Keeping the full patch stack avoids a +// second, diverging patch set for Linux. + const source = resolve(appDirectory, ".cache/native-source"); const run = async (cmd: string[], cwd = source): Promise => { @@ -150,6 +160,12 @@ if ( diffTrailingContextPatchPath, scrollbarVisibilityPatchPath, fontFamiliesPatchPath, + // Linux only, applied last: moves five macOS-only #[napi] methods + // out of the main GpuixRenderer impl. napi-derive registers cfg'd + // methods from the main impl even where their callbacks do not + // exist, so without this the Linux addon does not compile. + // macOS keeps the original patch stack unchanged. + ...(process.platform === "linux" ? [macosNapiMethodsPatchPath] : []), ]; // Later patches can change an earlier patch's context. Unwind known patches @@ -229,7 +245,10 @@ if ( resolve(source, "packages/native") ); await copyFile( - resolve(source, "packages/native/target/release/libgpuix_native.dylib"), + resolve( + source, + `packages/native/target/release/libgpuix_native.${process.platform === "darwin" ? "dylib" : "so"}` + ), `${nativeLibrary}.tmp` ); await rename(`${nativeLibrary}.tmp`, nativeLibrary); diff --git a/apps/gpuix/native/config.ts b/apps/gpuix/native/config.ts index c9b83846..272384fe 100644 --- a/apps/gpuix/native/config.ts +++ b/apps/gpuix/native/config.ts @@ -167,6 +167,15 @@ export const fontFamiliesPatchPath = resolve( import.meta.dir, "font-families.patch" ); +// Linux only: moves five macOS-only #[napi] methods out of the main +// GpuixRenderer impl into a macOS-only impl block. napi-derive registers +// cfg'd methods from the main impl even on Linux, where their callbacks +// do not exist, so the Linux addon fails to compile without this move. +// TS callers already guard these methods with `"method" in renderer`. +export const macosNapiMethodsPatchPath = resolve( + import.meta.dir, + "macos-napi-methods-linux.patch" +); export const nativeBuildId = createHash("sha256") .update(nativeRevision) .update(readFileSync(patchPath)) @@ -213,5 +222,6 @@ export const nativeBuildId = createHash("sha256") .update(readFileSync(diffTrailingContextPatchPath)) .update(readFileSync(scrollbarVisibilityPatchPath)) .update(readFileSync(fontFamiliesPatchPath)) + .update(readFileSync(macosNapiMethodsPatchPath)) .update(`${process.platform}-${process.arch}-release-v1`) .digest("hex"); diff --git a/apps/gpuix/native/macos-napi-methods-linux.patch b/apps/gpuix/native/macos-napi-methods-linux.patch new file mode 100644 index 00000000..22ff5dda --- /dev/null +++ b/apps/gpuix/native/macos-napi-methods-linux.patch @@ -0,0 +1,119 @@ +--- a/packages/native/src/renderer.rs ++++ b/packages/native/src/renderer.rs +@@ -1191,19 +1191,7 @@ + } + } + +- /// Synchronous native veto; set before starting a protected operation. +- #[cfg(target_os = "macos")] +- #[napi] +- pub fn set_close_blocked(&self, blocked: bool) { +- self.close_protection.0.lock().unwrap().0 = blocked; +- } + +- /// Consume a blocked close/quit attempt without queuing a later quit. +- #[cfg(target_os = "macos")] +- #[napi] +- pub fn take_close_requested(&self) -> bool { +- std::mem::take(&mut self.close_protection.0.lock().unwrap().1) +- } + + /// Initialize GPUI using the native event-loop architecture for this OS. + #[napi] +@@ -1822,39 +1810,8 @@ + )) + } + +- /// Installed CoreText families, without opening a system font panel. +- #[cfg(target_os = "macos")] +- #[napi] +- pub fn get_font_families(&self) -> Vec { +- crate::installed_font_families() +- } + +- /// Read the OS preference, unaffected by an explicit per-window appearance. +- #[cfg(target_os = "macos")] +- #[napi] +- pub fn get_system_appearance(&self) -> String { +- match gpui_macos::system_appearance() { +- gpui::WindowAppearance::Dark | gpui::WindowAppearance::VibrantDark => "dark", +- _ => "light", +- }.to_string() +- } + +- /// Match this window's AppKit material and menus to its explicit app theme. +- #[cfg(target_os = "macos")] +- #[napi] +- pub fn set_window_appearance(&self, appearance: String) -> Result<()> { +- let dark = match appearance.as_str() { +- "light" => false, +- "dark" => true, +- _ => return Err(Error::from_reason("Window appearance must be light or dark")), +- }; +- update_window_without_view(|window, _cx| -> Result<()> { +- gpui_macos::set_window_appearance(window, dark) +- .map_err(|error| Error::from_reason(error.to_string()))?; +- window.refresh(); +- Ok(()) +- })? +- } + + #[napi] + pub fn set_window_title(&self, title: String) -> Result<()> { +@@ -7546,3 +7503,56 @@ + } + } + } ++ ++#[cfg(target_os = "macos")] ++#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] ++#[napi] ++impl GpuixRenderer { ++ /// Synchronous native veto; set before starting a protected operation. ++ #[cfg(target_os = "macos")] ++ #[napi] ++ pub fn set_close_blocked(&self, blocked: bool) { ++ self.close_protection.0.lock().unwrap().0 = blocked; ++ } ++ ++ /// Consume a blocked close/quit attempt without queuing a later quit. ++ #[cfg(target_os = "macos")] ++ #[napi] ++ pub fn take_close_requested(&self) -> bool { ++ std::mem::take(&mut self.close_protection.0.lock().unwrap().1) ++ } ++ ++ /// Installed CoreText families, without opening a system font panel. ++ #[cfg(target_os = "macos")] ++ #[napi] ++ pub fn get_font_families(&self) -> Vec { ++ crate::installed_font_families() ++ } ++ ++ /// Read the OS preference, unaffected by an explicit per-window appearance. ++ #[cfg(target_os = "macos")] ++ #[napi] ++ pub fn get_system_appearance(&self) -> String { ++ match gpui_macos::system_appearance() { ++ gpui::WindowAppearance::Dark | gpui::WindowAppearance::VibrantDark => "dark", ++ _ => "light", ++ }.to_string() ++ } ++ ++ /// Match this window's AppKit material and menus to its explicit app theme. ++ #[cfg(target_os = "macos")] ++ #[napi] ++ pub fn set_window_appearance(&self, appearance: String) -> Result<()> { ++ let dark = match appearance.as_str() { ++ "light" => false, ++ "dark" => true, ++ _ => return Err(Error::from_reason("Window appearance must be light or dark")), ++ }; ++ update_window_without_view(|window, _cx| -> Result<()> { ++ gpui_macos::set_window_appearance(window, dark) ++ .map_err(|error| Error::from_reason(error.to_string()))?; ++ window.refresh(); ++ Ok(()) ++ })? ++ } ++} diff --git a/apps/gpuix/native/package-linux.ts b/apps/gpuix/native/package-linux.ts new file mode 100644 index 00000000..e0f0c181 --- /dev/null +++ b/apps/gpuix/native/package-linux.ts @@ -0,0 +1,199 @@ +import { + chmod, + copyFile, + cp, + mkdir, + mkdtemp, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { resolve } from "node:path"; +import { createHash } from "node:crypto"; +import { appDirectory, nativeLibrary } from "./config"; +import { copyImageRuntimeLinux } from "./package-resources"; +import { version as developmentVersion } from "../package.json"; +import { ReleaseVersion } from "./release-options"; +import { remoteHelperTargets } from "../src/feature/machines/remote-protocol"; + +/** + * Linux packaging: a self-contained tarball, not a .deb or AppImage yet. + * Layout mirrors the macOS bundle's relative paths so packaged-main.ts + * works unchanged: the compiled executable lives in bin/, the native + * addon in Frameworks/, and the JS app plus remote helpers in Resources/. + * No signing or Sparkle updater: on Linux there is no Sparkle, and + * AppUpdater.start() already no-ops off macOS. + * + * Build the tarball on Ubuntu 20.04 (or in a 20.04 container) to keep + * glibc 2.31 as the ceiling. A binary built on Ubuntu 24.04 (glibc 2.39) + * will not run on 20.04. + */ +if (process.platform !== "linux" || !["x64", "arm64"].includes(process.arch)) { + throw new Error("Linux packaging currently supports Linux x64/arm64 only."); +} + +const arch = process.arch === "x64" ? "x64" : "arm64"; + +const run = async (command: string[]): Promise => { + const child = Bun.spawn(command, { + cwd: appDirectory, + stdout: "inherit", + stderr: "inherit", + }); + + if ((await child.exited) !== 0) + throw new Error(`Linux packaging failed: ${command.join(" ")}`); +}; + +const version = ReleaseVersion.parse( + process.env.ONEGIT_RELEASE_VERSION ?? developmentVersion +); + +const output = resolve(appDirectory, "build/linux"); + +await mkdir(output, { recursive: true }); +const staging = await mkdtemp(resolve(output, ".package-")); +const root = resolve(staging, "1git"); +const resources = resolve(root, "Resources"); +const executable = resolve(root, "bin/1git"); +const addon = resolve(root, "Frameworks/gpuix-native.node"); +const archiveName = `1git-linux-${arch}.tar.gz`; +const archive = resolve(staging, archiveName); + +try { + await mkdir(resources, { recursive: true }); + await mkdir(resolve(root, "bin")); + await mkdir(resolve(root, "Frameworks")); + + await run([ + process.execPath, + "build", + "src/main.tsx", + "--target=bun", + "--production", + "--define", + `process.env.ONEGIT_APP_VERSION=${JSON.stringify(version)}`, + "--external=sharp", + "--outfile", + resolve(resources, "app.js"), + ]); + + await run([ + process.execPath, + "build", + "native/packaged-main.ts", + "--compile", + "--compile-autoload-package-json", + "--no-compile-autoload-dotenv", + "--no-compile-autoload-bunfig", + "--outfile", + executable, + ]); + await chmod(executable, 0o755); + await copyFile(nativeLibrary, addon); + await copyFile( + resolve(appDirectory, "../../LICENSE"), + resolve(resources, "1git-LICENSE.txt") + ); + await copyFile( + resolve(appDirectory, "../../THIRD_PARTY_NOTICES.md"), + resolve(resources, "THIRD_PARTY_NOTICES.md") + ); + await cp(resolve(appDirectory, "LICENSES"), resolve(resources, "LICENSES"), { + recursive: true, + }); + await copyFile( + resolve( + appDirectory, + "src/feature/repository-appearance/pattern-artwork.LICENSE" + ), + resolve(resources, "RepositoryPatterns.LICENSE") + ); + await copyImageRuntimeLinux(resources, arch); + // Linux tarball ships Linux remote helpers only: the darwin helpers + // cannot be cross-built from Linux (no Apple SDK/linker here). A + // Linux client can therefore deploy helpers to Linux remotes only + // in this build; macOS remotes need the macOS build's helpers. + const linuxHelperTargets = remoteHelperTargets.filter((target) => + target.startsWith("linux-") + ); + + await run([ + process.execPath, + "run", + "native/build-remote-helpers.ts", + ...linuxHelperTargets, + ]); + await mkdir(resolve(resources, "remote-helpers")); + + for (const target of linuxHelperTargets) { + for (const extension of ["gz", "json"]) { + await copyFile( + resolve(appDirectory, `build/remote-helpers/${target}.${extension}`), + resolve(resources, `remote-helpers/${target}.${extension}`) + ); + } + } + + await writeFile( + resolve(root, "1git.desktop"), + `[Desktop Entry] +Type=Application +Name=1git +Comment=A native Git workspace +Exec=1git +Icon=1git +Categories=Development;RevisionControl; +Terminal=false +` + ); + await writeFile( + resolve(root, "README.txt"), + `1git ${version} - Linux beta + +Requirements: a Wayland or X11 session, Git, and xdg-utils. For SSH +host prompts, install zenity. For clipboard, install wl-clipboard +(Wayland) or xclip (X11). Bun, Rust and Xcode are not needed to +run this app. + +glibc floor: this development build was made on Ubuntu 24.04 and +needs glibc 2.39 or newer, so it runs on Ubuntu 24.04+ but NOT on +20.04. A 20.04-compatible build must be built on Ubuntu 20.04 +itself (see LINUX-PORT.md); that is still pending. + +Install: +1. Extract this archive, e.g. to /opt/1git. +2. Run /opt/1git/bin/1git, or symlink it into /usr/local/bin/1git. +3. Optional: copy 1git.desktop to ~/.local/share/applications/ and + set Exec to the full path of bin/1git. + +No auto-updater is included in this Linux build. Accounts and +preferences are stored separately on your machine, not in this archive. +AI providers receive checked changes only when you choose to generate +a message. +` + ); + + await run(["tar", "-czf", archive, "-C", staging, "1git"]); + const hash = createHash("sha256"); + + for await (const chunk of Bun.file(archive).stream()) hash.update(chunk); + + await writeFile( + resolve(staging, `${archiveName}.sha256`), + `${hash.digest("hex")} ${archiveName}\n` + ); + + await rm(resolve(output, "1git"), { recursive: true, force: true }); + await rename(root, resolve(output, "1git")); + await rename(archive, resolve(output, archiveName)); + await rename( + resolve(staging, `${archiveName}.sha256`), + resolve(output, `${archiveName}.sha256`) + ); + process.stdout.write( + `Built ${resolve(output, "1git")}\nShare ${resolve(output, archiveName)}\n` + ); +} finally { + await rm(staging, { recursive: true, force: true }); +} diff --git a/apps/gpuix/native/package-resources.ts b/apps/gpuix/native/package-resources.ts index 8a5a309c..a417f1d0 100644 --- a/apps/gpuix/native/package-resources.ts +++ b/apps/gpuix/native/package-resources.ts @@ -30,6 +30,34 @@ export const copyImageRuntime = async (resources: string): Promise => { } }; +/** Linux counterpart of copyImageRuntime: the glibc (not musl) sharp runtime + * for the build host's architecture. Ubuntu 20.04 is glibc, so the + * linuxmusl packages must not be substituted here. */ +export const copyImageRuntimeLinux = async ( + resources: string, + arch: string +): Promise => { + const linuxPackages = [ + "sharp", + `@img/sharp-linux-${arch}`, + `@img/sharp-libvips-linux-${arch}`, + "@img/colour", + "detect-libc", + "semver", + ] as const; + + for (const name of linuxPackages) { + const manifest = sharpRequire.resolve(`${name}/package.json`); + const destination = resolve(resources, "node_modules", name); + + await mkdir(dirname(destination), { recursive: true }); + await cp(dirname(manifest), destination, { + recursive: true, + dereference: true, + }); + } +}; + export const bundleInfoPlist = ( version: string, updates?: UpdateConfiguration diff --git a/apps/gpuix/package.json b/apps/gpuix/package.json index ff5d59b1..aefe12c9 100644 --- a/apps/gpuix/package.json +++ b/apps/gpuix/package.json @@ -10,6 +10,7 @@ "setup:signing:macos": "bun native/setup-signing.ts", "test:signing:macos": "bun test tests/packaging/signing.integration.test.ts tests/packaging/hardened-runtime.integration.test.ts", "package:macos": "bun native/package-macos.ts", + "package:linux": "bun native/package-linux.ts", "test:package:macos": "bun test tests/packaging/launch.integration.test.ts", "test:updates:macos": "bun test tests/updater/install.integration.test.ts tests/updater/lifecycle.integration.test.ts tests/updater/preparation.integration.test.ts", "prepare:updates:macos": "bun native/prepare-updates.ts", diff --git a/apps/gpuix/src/feature/editor-settings/external-editors.ts b/apps/gpuix/src/feature/editor-settings/external-editors.ts index a957dbb5..3c83b223 100644 --- a/apps/gpuix/src/feature/editor-settings/external-editors.ts +++ b/apps/gpuix/src/feature/editor-settings/external-editors.ts @@ -515,9 +515,6 @@ export class ExternalEditors { /** Re-read the preference for each explicit file open. An unavailable choice fails instead of silently launching another app. */ async open(absolute: string, override?: string | null): Promise { - if (process.platform !== "darwin") - throw new Error("Open in editor currently requires macOS."); - if ( !isAbsolute(absolute) || (await realpath(absolute)) !== absolute || @@ -527,6 +524,19 @@ export class ExternalEditors { "Only existing regular files can be opened in an editor." ); + // Linux has no .app catalog yet (see LINUX-PORT.md). An explicit + // executable path is run directly; otherwise the default handler + // (xdg-open) is used. Catalog discovery for code/cursor/zed is TODO. + if (process.platform === "linux") { + if (override) await this.run(override, [absolute]); + else await this.run("xdg-open", [absolute]); + + return; + } + + if (process.platform !== "darwin") + throw new Error("Open in editor currently requires macOS."); + const selected = override === undefined ? ExternalEditors.resolve(absolute, this.preferences()) diff --git a/apps/gpuix/src/feature/machines/ssh-connection.ts b/apps/gpuix/src/feature/machines/ssh-connection.ts index 73ba8412..29676dee 100644 --- a/apps/gpuix/src/feature/machines/ssh-connection.ts +++ b/apps/gpuix/src/feature/machines/ssh-connection.ts @@ -133,6 +133,39 @@ export class SshConnection { if (saved) return saved; + // Linux has no osascript. zenity (GNOME, usually preinstalled on + // Ubuntu) provides the equivalent out-of-process dialog; a missing + // zenity or a cancelled dialog fails the SSH attempt, it never + // silently falls back to another credential source. + if (process.platform === "linux") { + if (confirmation) { + await execute( + "zenity", + ["--question", "--title=1git SSH", `--text=${text}`], + { + signal: this.controller.signal, + maxBuffer: 16384, + } + ); + + return "yes"; + } + + const result = await execute( + "zenity", + ["--password", "--title=1git SSH", `--text=${text}`], + { + signal: this.controller.signal, + maxBuffer: 16384, + } + ); + + return result.stdout.replace(/\r?\n$/, ""); + } + + if (process.platform !== "darwin") + throw new Error("SSH prompts are not supported on this platform."); + const script = confirmation ? 'on run argv\ndisplay dialog (item 1 of argv) buttons {"Cancel", "Trust host"} default button "Cancel" with title "1git SSH"\nreturn "yes"\nend run' : 'on run argv\nset answer to display dialog (item 1 of argv) default answer "" with hidden answer buttons {"Cancel", "Connect"} default button "Connect" with title "1git SSH"\nreturn text returned of answer\nend run'; diff --git a/apps/gpuix/src/feature/repository-workspace/lib/repository-file-actions.ts b/apps/gpuix/src/feature/repository-workspace/lib/repository-file-actions.ts index c52acf32..2925fe26 100644 --- a/apps/gpuix/src/feature/repository-workspace/lib/repository-file-actions.ts +++ b/apps/gpuix/src/feature/repository-workspace/lib/repository-file-actions.ts @@ -29,6 +29,26 @@ import { ExternalEditors } from "../../editor-settings/external-editors"; import type { CommitFileSelection } from "../types"; const execute = promisify(execFile); + +/** Open a file or folder with the OS default handler. No shell interpolation. */ +async function openWithDefaultApp(absolute: string): Promise { + if (process.platform === "darwin") + await execute("/usr/bin/open", [absolute], { timeout: 10_000 }); + else if (process.platform === "linux") + await execute("xdg-open", [absolute], { timeout: 10_000 }); + else throw new Error("Opening files is not supported on this platform."); +} + +/** Reveal a path in the OS file manager. xdg-open has no -R equivalent, so + * on Linux opening the parent folder is the closest match. */ +async function revealInFileManager(absolute: string): Promise { + if (process.platform === "darwin") + await execute("/usr/bin/open", ["-R", absolute], { timeout: 10_000 }); + else if (process.platform === "linux") + await execute("xdg-open", [join(absolute, "..")], { timeout: 10_000 }); + else throw new Error("Revealing files is not supported on this platform."); +} + const nullObjectId = /^0+$/u; const objectId = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/iu; @@ -103,7 +123,7 @@ export class RepositoryFileActions { ); if (kind === "editor") await editors.open(absolute, application); - else await execute("/usr/bin/open", [absolute], { timeout: 10_000 }); + else await openWithDefaultApp(absolute); } catch (cause) { await rm(directory, { recursive: true, force: true }); @@ -121,7 +141,7 @@ export class RepositoryFileActions { const path = await realpath(directory); - await execute("/usr/bin/open", ["-R", path], { timeout: 10_000 }); + await openWithDefaultApp(path); } static async revealRecovery(directory: string) { @@ -130,7 +150,7 @@ export class RepositoryFileActions { `Recovery files are on your SSH machine: ${MachineLocation.display(directory)}` ); - await execute("/usr/bin/open", [directory], { timeout: 10_000 }); + await openWithDefaultApp(directory); } static async open( root: string, @@ -197,11 +217,8 @@ export class RepositoryFileActions { "Only existing regular files inside the repository can be opened or revealed." ); - await execute( - "/usr/bin/open", - kind === "finder" ? ["-R", absolute] : [absolute], - { timeout: 10_000 } - ); + if (kind === "finder") await revealInFileManager(absolute); + else await openWithDefaultApp(absolute); } static async ignore(root: string, request: FileIgnoreRequest) { diff --git a/apps/gpuix/src/platform/browser.ts b/apps/gpuix/src/platform/browser.ts index 1da5938e..a33f9fcf 100644 --- a/apps/gpuix/src/platform/browser.ts +++ b/apps/gpuix/src/platform/browser.ts @@ -12,15 +12,26 @@ export class NativeBrowser { if (url.protocol !== "https:" || url.username || url.password || url.hash) throw new Error("Invalid browser URL."); - if (process.platform !== "darwin") - throw new Error("Opening the browser is currently supported on macOS."); + const command = + process.platform === "darwin" + ? ["/usr/bin/open", url.href] + : process.platform === "linux" + ? ["xdg-open", url.href] + : null; - const child = Bun.spawn(["/usr/bin/open", url.href], { - stdout: "ignore", - stderr: "ignore", - }); + if (!command) + throw new Error("Opening the browser is not supported on this platform."); - if ((await child.exited) !== 0) + try { + const child = Bun.spawn(command, { + stdout: "ignore", + stderr: "ignore", + }); + + if ((await child.exited) !== 0) + throw new Error("Could not open the browser. Try again."); + } catch { throw new Error("Could not open the browser. Try again."); + } } } diff --git a/apps/gpuix/src/platform/clipboard.ts b/apps/gpuix/src/platform/clipboard.ts index ee3e1f72..446dc96e 100644 --- a/apps/gpuix/src/platform/clipboard.ts +++ b/apps/gpuix/src/platform/clipboard.ts @@ -1,16 +1,39 @@ -/** Shared macOS text clipboard boundary. Values travel through stdin, never shell commands. */ +/** Shared text clipboard boundary. Values travel through stdin, never shell commands. */ export class NativeClipboard { static async write(value: string): Promise { - if (process.platform !== "darwin") - throw new Error("Clipboard copying is currently supported on macOS."); + // Wayland first, then X11. Each candidate is tried in order; a missing + // binary exits non-zero and falls through to the next one. + const commands = + process.platform === "darwin" + ? [["/usr/bin/pbcopy"]] + : process.platform === "linux" + ? [ + ["wl-copy"], + ["xclip", "-selection", "clipboard"], + ["xsel", "--clipboard", "--input"], + ] + : []; - const child = Bun.spawn(["/usr/bin/pbcopy"], { - stdin: new TextEncoder().encode(value), - stdout: "ignore", - stderr: "ignore", - }); + if (!commands.length) + throw new Error("Clipboard copying is not supported on this platform."); - if ((await child.exited) !== 0) - throw new Error("Could not copy to the clipboard."); + for (const command of commands) { + try { + const child = Bun.spawn(command, { + stdin: new TextEncoder().encode(value), + stdout: "ignore", + stderr: "ignore", + }); + + if ((await child.exited) === 0) return; + } catch { + // Bun.spawn throws when the binary is missing; try the next one. + continue; + } + } + + throw new Error( + "Could not copy to the clipboard. On Linux, install wl-clipboard (Wayland) or xclip (X11)." + ); } } diff --git a/apps/gpuix/src/window-options.ts b/apps/gpuix/src/window-options.ts index 87434fe6..ff8ae52d 100644 --- a/apps/gpuix/src/window-options.ts +++ b/apps/gpuix/src/window-options.ts @@ -10,7 +10,9 @@ import { handleRepositoryFilterKeyDown } from "./feature/repository-workspace/li import { handleWorkspaceKeyDown } from "./feature/repository-workspace/lib/repository-filter-shortcut"; import { handleCloneNavigation } from "./feature/repository-workspace/lib/repository-filter-shortcut"; -/** Native window chrome and keyboard traversal; application content stays transparent around the workspace. */ +/** Native window chrome and keyboard traversal. On Linux there is no AppKit + * vibrancy or traffic-light chrome, so the window keeps the compositor's + * native decorations and an opaque background instead of the macOS look. */ export const windowOptions: RenderOptions = { height: 820, width: 1280, @@ -18,11 +20,15 @@ export const windowOptions: RenderOptions = { minWidth: 640, minHeight: 420, title: "1git", - titlebarTransparent: true, - appOwnsTitlebarDrag: true, - windowBackground: "blurred", - trafficLightX: 16, - trafficLightY: 13, + ...(process.platform === "darwin" + ? { + titlebarTransparent: true, + appOwnsTitlebarDrag: true, + windowBackground: "blurred", + trafficLightX: 16, + trafficLightY: 13, + } + : {}), onKeyDown(event, renderer) { if (handlePopoverKeyDown(event, renderer)) return; diff --git a/package.json b/package.json index c20bb7a7..d58146b7 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "build:web": "turbo run build --filter=@1git/web", "build": "turbo run build", "package:macos": "turbo run package:macos --filter=@1git/gpuix", + "package:linux": "turbo run package:linux --filter=@1git/gpuix", "setup:signing:macos": "turbo run setup:signing:macos --filter=@1git/gpuix", "test:signing:macos": "turbo run test:signing:macos --filter=@1git/gpuix", "test:package:macos": "turbo run test:package:macos --filter=@1git/gpuix",