diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 88b3a05..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 ntgn - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md index b332480..db623b6 100644 --- a/README.md +++ b/README.md @@ -117,11 +117,33 @@ The following options are available under `programs.helium`: | `package` | package | `self.packages.helium` | The helium package to use. | | `extensions` | list of submodules | `[]` | Extensions to install: `{ id, hash }` (`hash` only needed in `unpacked` mode). | | `extensionInstallMode` | `"policy"` or `"unpacked"` | `"policy"` | How extensions are installed — see below. | -| `extraFlags` | list of strings | `[]` | Command line arguments passed to the wrapper. | +| `extraFlags` | list of strings | `[]` | Command line arguments passed to the wrapper. Do not pass `--enable-features` here — use `extraFeatures`. | +| `extraFeatures` | list of strings | `[]` | Chromium features merged into the single `--enable-features` switch. | +| `allowFileAccessFromFiles` | boolean | `false` | Pass `--allow-file-access-from-files`. Lets any local `file://` page read other files on disk — see below. | | `extraPolicies` | attribute set | `{}` | Raw Chromium policies to apply. | | `preferences` | attribute set | `{}` | Json that will be merged into XDG Config. | | `defaultBrowser` | boolean | `false` | Set Helium as the default browser in XDG mimeapps. | +## Flags and features + +Chromium stores one value per command line switch, so a second +`--enable-features=…` silently *replaces* the first instead of adding to it. +Both the package and the home-manager wrapper need to enable features, so they +build a single combined switch: the package exposes its list as +`passthru.enabledFeatures`, and the module merges its own additions and your +`extraFeatures` into it. + +That is why features go in `extraFeatures` rather than `extraFlags` — a raw +`--enable-features` in `extraFlags` would come last and wipe out everything +else. + +> [!WARNING] +> `allowFileAccessFromFiles` drops the same-origin restriction for `file://` +> pages. Any local HTML file you open — including one you just downloaded — can +> then read other files on your disk and send them anywhere. It is off by +> default; only turn it on if you specifically need local pages to load local +> resources. + ## Policies Since Helium is based on Chromium, you can use any of the standard Chromium @@ -140,6 +162,14 @@ These are usually what you imperatively choose in the `Settings` menu. You can find all the json keys and values inside the helium browser by typing `helium://prefs-internals/` and searching for the values. +> [!IMPORTANT] +> The merge happens once, at activation time. Chromium owns this file while it +> runs and rewrites it from memory on exit, so close the browser before you +> activate or your settings get written back over. Preferences that Chromium +> protects with a MAC in `Secure Preferences` cannot be set this way at all — it +> spots the outside edit and resets them. Use `extraPolicies` for anything that +> has to stick. + ```nix { programs.helium.preferences = { @@ -182,10 +212,23 @@ entry needs a `hash` in this mode; see below. Only needed for `extensionInstallMode = "unpacked"`. -> [!WARNING] -> Be wary that if you are rate-limited that the file will be empty and the build will fail +Use the `prefetch-nix` binary, which the package installs alongside the browser, +to obtain the nix code you need. -Use the provided binary `prefetch-nix-extension` to obtain the nix code you need. +The Web Store decides which build of an extension to serve from the +`prodversion`, `os` and `arch` in the request, so a hash is only valid for the +exact URL it came from. `prefetch-nix` is wrapped by the home-manager module so +it already asks for the same URL the module will later fetch. If you run the +script straight out of the repo instead, override the defaults with +`HELIUM_PRODVERSION`, `HELIUM_OS`, `HELIUM_ARCH`, `HELIUM_OS_ARCH` and +`HELIUM_NACL_ARCH` to match your `programs.helium.prodversion` and target +platform, or the hash will not match at build time. + +> [!NOTE] +> If the store rate-limits you it answers with an error page rather than a CRX. +> `prefetch-nix` checks for the CRX3 magic and reports `# FAILED` instead of +> printing a hash for it, and the build refuses such a file too — so a +> rate-limited fetch can never end up as a silently empty extension. You can copy the IDs from the URL in the chrome web store: @@ -197,7 +240,7 @@ https://chromewebstore.google.com/detail/bitwarden-password-manage/nngceckbapebf Example Usage: ```console -./prefetch-nix-extension.sh nngceckbapebfimnlniiiahkandclblb cjpalhdlnbpafiamejdnhcphjbkeiagm +prefetch-nix nngceckbapebfimnlniiiahkandclblb cjpalhdlnbpafiamejdnhcphjbkeiagm # OUTPUT: extensions = [ diff --git a/modules/home-manager.nix b/modules/home-manager.nix index 32c5ff1..b5ae570 100644 --- a/modules/home-manager.nix +++ b/modules/home-manager.nix @@ -7,17 +7,43 @@ }: let cfg = config.programs.helium; + webStorePlatform = + let + inherit (pkgs.stdenv.hostPlatform) isDarwin isAarch64; + in + { + os = if isDarwin then "mac" else "linux"; + arch = if isAarch64 then "arm64" else "x64"; + os_arch = + if !isAarch64 then + "x86_64" + else if isDarwin then + "arm64" + else + "aarch64"; + nacl_arch = if isAarch64 then "arm" else "x86-64"; + }; + + # Shared by fetchExtension and the prefetch-nix wrapper, so a hash obtained by + # hand is always a hash for the CRX the module will go on to download. + crxUrl = + id: + "https://clients2.google.com/service/update2/crx" + + "?response=redirect" + + "&os=${webStorePlatform.os}" + + "&arch=${webStorePlatform.arch}" + + "&os_arch=${webStorePlatform.os_arch}" + + "&nacl_arch=${webStorePlatform.nacl_arch}" + + "&prod=chromiumcrx&prodchannel=stable" + + "&prodversion=${cfg.prodversion}" + + "&acceptformat=crx3" + + "&x=id%3D${id}%26installsource%3Dondemand%26uc"; fetchExtension = { id, hash }: - let - os = if pkgs.stdenv.isDarwin then "mac" else "linux"; - arch = if pkgs.stdenv.isAarch64 then "arm64" else "x64"; - os_arch = if pkgs.stdenv.isDarwin then "arm64" else "x86_64"; - in pkgs.fetchurl { name = "${id}.crx"; - url = "https://clients2.google.com/service/update2/crx?response=redirect&os=${os}&arch=${arch}&os_arch=${os_arch}&nacl_arch=x86-64&prod=chromiumcrx&prodchannel=stable&prodversion=${cfg.prodversion}&acceptformat=crx3&x=id%3D${id}%26installsource%3Dondemand%26uc"; + url = crxUrl id; inherit hash; }; @@ -29,10 +55,6 @@ let src = fetchExtension { inherit id hash; }; } '' - # The Web Store answers 204 No Content for extensions that need a newer - # browser than `prodversion` claims. fetchurl stores that empty body - # happily, so without this check the build succeeds and the extension is - # silently missing at runtime. if [ ! -s "$src" ]; then echo "error: the CRX for ${id} is empty." >&2 echo "The Chrome Web Store returned no content, which usually means" >&2 @@ -41,11 +63,23 @@ let exit 1 fi + if [ "$(head -c 4 "$src")" != "Cr24" ]; then + echo "error: the CRX for ${id} is not a CRX3 file." >&2 + echo "The Chrome Web Store served something else — an error or rate" >&2 + echo "limit page, most likely. Re-run the hash prefetch." >&2 + exit 1 + fi + mkdir -p $out - unzip -q $src -d $out || true + unzip -q $src -d $out || [ "$?" -le 1 ] # Remove the system-reserved metadata folder that causes the load error rm -rf $out/_metadata + + if [ ! -f "$out/manifest.json" ]; then + echo "error: the CRX for ${id} unpacked without a manifest.json." >&2 + exit 1 + fi ''; unpackedMode = cfg.extensionInstallMode == "unpacked"; @@ -57,10 +91,6 @@ let }) cfg.extensions ); - # Helium's update requests carry no prodversion, and the Web Store answers - # every one of them with — force-installs - # then silently do nothing. Pinning it in the update URL survives into the - # request Chromium builds, since it only appends its own query parameters. webStoreUpdateUrl = "https://clients2.google.com/service/update2/crx?prodversion=${cfg.prodversion}"; policyAttrs = { @@ -82,6 +112,15 @@ let else [ ]; + enabledFeatures = lib.unique ( + (cfg.package.enabledFeatures or [ ]) + ++ lib.optionals pkgs.stdenv.hostPlatform.isLinux [ + "NativeNotifications" + "SystemNotifications" + ] + ++ cfg.extraFeatures + ); + heliumWithFlags = pkgs.symlinkJoin { name = "helium-configured"; paths = [ cfg.package ]; @@ -91,15 +130,26 @@ let ${lib.concatMapStringsSep " \\\n " (f: "--add-flags ${lib.escapeShellArg f}") ( [ "--disable-component-update" - "--allow-file-access-from-files" ] + ++ lib.optional cfg.allowFileAccessFromFiles "--allow-file-access-from-files" ++ loadExtensionFlag - ++ lib.optionals pkgs.stdenv.isLinux [ + ++ lib.optionals pkgs.stdenv.hostPlatform.isLinux [ "--ozone-platform-hint=auto" - "--enable-features=WaylandWindowDecorations,NativeNotifications,SystemNotifications" ] + ++ lib.optional ( + enabledFeatures != [ ] + ) "--enable-features=${lib.concatStringsSep "," enabledFeatures}" ++ cfg.extraFlags )} + + if [ -e $out/bin/prefetch-nix ]; then + wrapProgram $out/bin/prefetch-nix \ + --set-default HELIUM_PRODVERSION ${lib.escapeShellArg cfg.prodversion} \ + --set-default HELIUM_OS ${lib.escapeShellArg webStorePlatform.os} \ + --set-default HELIUM_ARCH ${lib.escapeShellArg webStorePlatform.arch} \ + --set-default HELIUM_OS_ARCH ${lib.escapeShellArg webStorePlatform.os_arch} \ + --set-default HELIUM_NACL_ARCH ${lib.escapeShellArg webStorePlatform.nacl_arch} + fi ''; }; @@ -167,6 +217,34 @@ in extraFlags = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; + description = '' + Extra command line arguments added to the wrapper. + + Do not pass `--enable-features` here: Chromium keeps a single value per + switch, so it would replace the feature list the wrapper builds instead + of adding to it. Use `extraFeatures` for that. + ''; + }; + extraFeatures = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = '' + Additional Chromium features, merged into the single `--enable-features` + switch alongside the ones the package and this module already enable. + ''; + example = [ "VaapiVideoDecodeLinuxGL" ]; + }; + allowFileAccessFromFiles = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Pass `--allow-file-access-from-files`. + + This drops the same-origin restriction for `file://` pages, so any local + HTML file you open — including one you just downloaded — can read other + files on disk and send them off. Only enable it if you specifically need + local pages to load local resources. + ''; }; extraPolicies = lib.mkOption { type = lib.types.attrsOf lib.types.anything; @@ -190,6 +268,13 @@ in Chromium preferences to set in the Default profile. These are merged into ~/.config/net.imput.helium/Default/Preferences. Type: 'helium://prefs-internals/' to search for the json keys and values + + The merge happens once, during activation. Chromium owns this file at + runtime and rewrites it from memory when it exits, so close the browser + before activating or your settings are written back over. Preferences + that Chromium protects with a MAC in Secure Preferences cannot be set + this way at all — it notices the outside edit and resets them. Use + `extraPolicies` for anything that has to stick. ''; example = lib.literalExpression '' { @@ -234,21 +319,26 @@ in heliumPreferences = lib.mkIf (cfg.preferences != { }) ( lib.hm.dag.entryAfter [ "writeBoundary" ] '' - prefs_dir="$HOME/.config/net.imput.helium/Default" - prefs_file="$prefs_dir/Preferences" - nix_prefs='${builtins.toJSON cfg.preferences}' + prefs_file="$HOME/.config/net.imput.helium/Default/Preferences" + nix_prefs=${lib.escapeShellArg (builtins.toJSON cfg.preferences)} + merged_prefs="$(mktemp)" - run mkdir -p "$prefs_dir" - - if [ -f "$prefs_file" ]; then - merged=$(${pkgs.jq}/bin/jq -s '.[0] * .[1]' "$prefs_file" - <<< "$nix_prefs") - if [ -n "$merged" ]; then - printf '%s\n' "$merged" > "$prefs_file" + if [ -f "$prefs_file" ]; then + if ${lib.getExe pkgs.jq} -s '.[0] * .[1]' "$prefs_file" - \ + <<< "$nix_prefs" > "$merged_prefs"; then + run ${lib.getExe' pkgs.coreutils "install"} -Dm600 \ + "$merged_prefs" "$prefs_file" + else + warnEcho "helium: $prefs_file is not valid JSON; leaving it alone." + fi + else + printf '%s\n' "$nix_prefs" > "$merged_prefs" + run ${lib.getExe' pkgs.coreutils "install"} -Dm600 \ + "$merged_prefs" "$prefs_file" fi - else - printf '%s\n' "$nix_prefs" > "$prefs_file" - fi - '' + + rm -f "$merged_prefs" + '' ); }; }; diff --git a/modules/outputs.nix b/modules/outputs.nix index 1e48faa..16e8c00 100644 --- a/modules/outputs.nix +++ b/modules/outputs.nix @@ -5,7 +5,8 @@ let sources = import ./sources.nix; - version = if pkgs.stdenv.isDarwin then sources.versions.darwin else sources.versions.linux; + version = + if pkgs.stdenv.hostPlatform.isDarwin then sources.versions.darwin else sources.versions.linux; src = pkgs.fetchurl ( (sources.srcs.${system} or (throw "Unsupported system: ${system}")) sources.versions @@ -36,7 +37,7 @@ default = helium; inherit helium; } - // pkgs.lib.optionalAttrs pkgs.stdenv.isLinux { inherit helium-widevine; }; + // pkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { inherit helium-widevine; }; apps = { default = app; diff --git a/modules/package.nix b/modules/package.nix index 71bbf5b..9a86925 100644 --- a/modules/package.nix +++ b/modules/package.nix @@ -58,19 +58,24 @@ src, }: let + enabledFeatures = [ + "StorageAccessAPI" + ] + ++ lib.optionals stdenv.hostPlatform.isLinux [ + "WaylandWindowDecorations" + ]; + # Chromium flags applied on all platforms to disable update machinery. commonFlags = [ "--disable-component-update" "--simulate-outdated-no-au='Tue, 31 Dec 2099 23:59:59 GMT'" "--check-for-update-interval=0" "--no-first-run" - "--enable-features=StorageAccessAPI" + "--enable-features=${lib.concatStringsSep "," enabledFeatures}" "--restore-last-session" ]; addFlags = lib.concatMapStringsSep " \\\n " (f: "--add-flags \"${f}\""); - - # Libraries that must appear on LD_LIBRARY_PATH at runtime on Linux. linuxRuntimeLibs = [ libGL libvdpau @@ -87,13 +92,13 @@ stdenv.mkDerivation { nativeBuildInputs = [ makeWrapper ] - ++ lib.optionals stdenv.isLinux [ + ++ lib.optionals stdenv.hostPlatform.isLinux [ autoPatchelfHook copyDesktopItems ] - ++ lib.optionals stdenv.isDarwin [ _7zz ]; + ++ lib.optionals stdenv.hostPlatform.isDarwin [ _7zz ]; - buildInputs = lib.optionals stdenv.isLinux [ + buildInputs = lib.optionals stdenv.hostPlatform.isLinux [ alsa-lib at-spi2-atk at-spi2-core @@ -138,7 +143,7 @@ stdenv.mkDerivation { ]; # QT libraries are bundled; suppress autoPatchelf warnings for them. - autoPatchelfIgnoreMissingDeps = lib.optionals stdenv.isLinux [ + autoPatchelfIgnoreMissingDeps = lib.optionals stdenv.hostPlatform.isLinux [ "libQt6Core.so.6" "libQt6Gui.so.6" "libQt6Widgets.so.6" @@ -147,17 +152,17 @@ stdenv.mkDerivation { "libQt5Widgets.so.5" ]; - dontWrapQtApps = stdenv.isLinux; - - unpackCmd = lib.optionalString stdenv.isDarwin "7zz x $src"; + dontWrapQtApps = stdenv.hostPlatform.isLinux; + unpackCmd = lib.optionalString stdenv.hostPlatform.isDarwin "7zz x -odmg-contents \"$curSrc\""; + sourceRoot = lib.optionalString stdenv.hostPlatform.isDarwin "dmg-contents"; installPhase = - if stdenv.isDarwin then + if stdenv.hostPlatform.isDarwin then '' runHook preInstall - mkdir -p $out/Applications/Helium.app - cp -r . $out/Applications/Helium.app + mkdir -p $out/Applications + cp -r Helium.app $out/Applications/Helium.app mkdir -p $out/bin makeWrapper $out/Applications/Helium.app/Contents/MacOS/Helium $out/bin/helium \ @@ -171,14 +176,12 @@ stdenv.mkDerivation { mkdir -p $out/bin $out/opt/helium cp -r * $out/opt/helium -${lib.optionalString enableWidevine '' - cp -r ${widevine-cdm}/share/google/chrome/WidevineCdm $out/opt/helium/WidevineCdm -''} - + ${lib.optionalString enableWidevine '' + cp -r ${widevine-cdm}/share/google/chrome/WidevineCdm $out/opt/helium/WidevineCdm + ''} makeWrapper $out/opt/helium/helium $out/bin/helium \ --prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath linuxRuntimeLibs}" \ --add-flags "--ozone-platform-hint=auto" \ - --add-flags "--enable-features=WaylandWindowDecorations" \ ${addFlags commonFlags} mkdir -p $out/share/icons/hicolor/256x256/apps @@ -195,7 +198,7 @@ ${lib.optionalString enableWidevine '' chmod +x $out/bin/prefetch-nix ''; - desktopItems = lib.optionals stdenv.isLinux [ + desktopItems = lib.optionals stdenv.hostPlatform.isLinux [ (makeDesktopItem { name = "helium"; exec = "helium %U"; @@ -217,6 +220,7 @@ ${lib.optionalString enableWidevine '' }) ]; + passthru = { inherit enabledFeatures; }; meta = { description = "Private, fast, and honest web browser based on ungoogled-chromium"; homepage = "https://helium.computer/"; diff --git a/scripts/prefetch-nix-extension.sh b/scripts/prefetch-nix-extension.sh index 1d1e81a..40777fc 100755 --- a/scripts/prefetch-nix-extension.sh +++ b/scripts/prefetch-nix-extension.sh @@ -1,39 +1,75 @@ #!/usr/bin/env bash -# check if at least one ID was provided +set -euo pipefail + if [ $# -eq 0 ]; then - echo "Usage: fetchext [ ...]" - echo "Example: fetchext nngceckbapebfimnlniiiahkandclblb" + cat >&2 <<'USAGE' +Usage: prefetch-nix [ ...] + +Example: prefetch-nix nngceckbapebfimnlniiiahkandclblb + +Environment (defaults follow programs.helium and the host platform): + HELIUM_PRODVERSION Chrome version advertised to the Web Store + HELIUM_OS "linux" or "mac" + HELIUM_ARCH "x64" or "arm64" + HELIUM_OS_ARCH uname machine name, e.g. "x86_64" / "aarch64" / "arm64" + HELIUM_NACL_ARCH "x86-64" or "arm" +USAGE exit 1 fi +case "$(uname -s)" in + Darwin) default_os="mac" ;; + *) default_os="linux" ;; +esac -UA="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" +case "$(uname -m)" in + arm64 | aarch64) + default_arch="arm64" + default_nacl_arch="arm" + if [ "$default_os" = "mac" ]; then + default_os_arch="arm64" + else + default_os_arch="aarch64" + fi + ;; + *) + default_arch="x64" + default_nacl_arch="x86-64" + default_os_arch="x86_64" + ;; +esac + +prodversion="${HELIUM_PRODVERSION:-151.0.0.0}" +os="${HELIUM_OS:-$default_os}" +arch="${HELIUM_ARCH:-$default_arch}" +os_arch="${HELIUM_OS_ARCH:-$default_os_arch}" +nacl_arch="${HELIUM_NACL_ARCH:-$default_nacl_arch}" + +UA="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${prodversion} Safari/537.36" + +echo "# prodversion=$prodversion os=$os arch=$arch os_arch=$os_arch" >&2 # print opening for easy copy-pasting echo "extensions = [" +status=0 + for id in "$@"; do - # fetch CRX to calculate the hash - url="https://clients2.google.com/service/update2/crx?response=redirect&os=linux&arch=x64&os_arch=x86_64&nacl_arch=x86-64&prod=chromiumcrx&prodchannel=stable&prodversion=120.0.0.0&acceptformat=crx3&x=id%3D${id}%26installsource%3Dondemand%26uc" + url="https://clients2.google.com/service/update2/crx?response=redirect&os=${os}&arch=${arch}&os_arch=${os_arch}&nacl_arch=${nacl_arch}&prod=chromiumcrx&prodchannel=stable&prodversion=${prodversion}&acceptformat=crx3&x=id%3D${id}%26installsource%3Dondemand%26uc" tmpfile=$(mktemp) - # -L follows the redirect from Google's service to the actual .crx file if curl -fsSL -A "$UA" "$url" -o "$tmpfile"; then - size=$(stat -c%s "$tmpfile") - - if [ "$size" -gt 1000 ]; then - # generate SRI hash - hash=$(nix-hash --type sha256 --flat --base32 "$tmpfile") - sri=$(nix hash convert --to sri --hash-algo sha256 "$hash") - - # output the Nix code block - echo " { id = \"$id\"; hash = \"$sri\"; }" + if [ "$(head -c 4 "$tmpfile")" = "Cr24" ]; then + hash=$(nix hash file --type sha256 --sri "$tmpfile") + echo " { id = \"$id\"; hash = \"$hash\"; }" else - echo " # FAILED: ID $id returned an empty or invalid file (check ID)." + echo " # FAILED: $id — the store returned no CRX (try raising HELIUM_PRODVERSION)." + status=1 fi else echo " # FAILED: Could not reach Google Update servers for ID $id." + status=1 fi rm -f "$tmpfile" @@ -42,3 +78,5 @@ for id in "$@"; do done echo "];" + +exit "$status"