diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 885a303..66bba99 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,3 +22,12 @@ jobs: with: github_access_token: ${{ secrets.GITHUB_TOKEN }} - run: nix flake check + - name: Run Nix tests + run: | + cd tests/ + result=$(nix eval --impure --expr 'import ./ref-test.nix {}') + if [ "$result" != "[ ]" ]; then + echo "Test failed: Expected [], but got $result" + exit 1 + fi + shell: bash diff --git a/README.md b/README.md index da44457..19d08ea 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,22 @@ You can pin a specific commit setting `commit=` attribute. Rebuild your system (or home-manager) for changes to take place. +#### Flatpakref files +[Flatpakref](https://docs.flatpak.org/en/latest/repositories.html#flatpakref-files) files can be installed by setting the `flatpakref` attribute to : +```nix + services.flatpak.packages = [ + { flatpakref = ""; sha256=""; } + ]; +``` + +A `sha256` hash is required for the flatpakref file. This can be generated with `nix-prefetch-url `. +Omitting the `sha256` attribute will require an `impure` evaluation of the flake. + +When installing an application from a `flatpakref`, the application remote will be determined as follows: +1. If the packageOptions contains an origin, use that as the label for the remote URL. +2. If the package does not specify an origin, use the remote name suggested by the flatpakref (SuggestRemoteName). +3. If neither the package sets an origin nor the flatpakref suggests a remote name, sanitize the application Name. + ##### Unmanaged packages and remotes By default `nix-flatpak` will only manage (install/uninstall/update) packages declared in the diff --git a/modules/installer.nix b/modules/installer.nix index 08799c2..8fa7f1d 100644 --- a/modules/installer.nix +++ b/modules/installer.nix @@ -1,6 +1,30 @@ { cfg, pkgs, lib, installation ? "system", ... }: let + utils = import ./ref.nix { inherit lib; }; + + flatpakrefCache = builtins.foldl' + (acc: package: + acc // utils.flatpakrefToAttrSet package acc + ) + { } + (builtins.filter (package: utils.isFlatpakref package) cfg.packages); + + # Get the appId from the flatpakref file or the flatpakref URL to pass to flatpak commands. + # As of 2024-10 Flatpak will fail to reinstall from flatpakref URL (https://github.com/flatpak/flatpak/issues/5460). + # This function will return the appId if the package is already installed, otherwise it will return the flatpakref URL. + getAppIdOrRef = flatpakrefUrl: installation: + let + appId = flatpakrefCache.${(utils.sanitizeUrl flatpakrefUrl)}.Name; + in + '' + $(if ${pkgs.flatpak}/bin/flatpak --${installation} list --app --columns=application | ${pkgs.gnugrep}/bin/grep -q ${appId}; then + echo "${appId}" + else + echo "--from ${flatpakrefUrl}" + fi) + ''; + # Put the state file in the `gcroots` folder of the respective installation, # which prevents it from being garbage collected. This could probably be # improved in the future if there are better conventions for how this should @@ -23,10 +47,25 @@ let if (installation == "system") then "/nix/var/nix/gcroots/" else "\${XDG_STATE_HOME:-$HOME/.local/state}/home-manager/gcroots"; + stateFile = pkgs.writeText "flatpak-state.json" (builtins.toJSON { - packages = map (builtins.getAttr "appId") cfg.packages; + packages = (map + (package: + if utils.isFlatpakref package + then flatpakrefCache.${(utils.sanitizeUrl package.flatpakref)}.Name # application id from flatpakref + else package.appId + ) + cfg.packages); overrides = cfg.overrides; - remotes = map (builtins.getAttr "name") cfg.remotes; + # Iterate over remotes and handle remotes installed from flatpakref URLs + remotes = + # Existing remotes (not from flatpakref) + (map (builtins.getAttr "name") cfg.remotes) ++ + # Add remotes extracted from flatpakref URLs in packages + map + (package: + utils.getRemoteNameFromFlatpakref package.origin flatpakrefCache.${(utils.sanitizeUrl package.flatpakref)}) + (builtins.filter (package: utils.isFlatpakref package) cfg.packages); }); statePath = "${gcroots}/${stateFile.name}"; @@ -46,7 +85,7 @@ let --arg installed_packages "$INSTALLED_PACKAGES" \ '$old + { "packages" : $installed_packages | split("\n") }') - # Add all configured remoted to the old state, so that only managed ones will be kept across generations. + # Add all configured remote to the old state, so that only managed ones will be kept across generations. MANAGED_REMOTES=$(${pkgs.flatpak}/bin/flatpak --${installation} remotes --columns=name) OLD_STATE=$(${pkgs.jq}/bin/jq -r -n \ @@ -72,6 +111,7 @@ let if (installation == "system") then "/var/lib/flatpak/overrides" else "\${XDG_DATA_HOME:-$HOME/.local/share}/flatpak/overrides"; + flatpakOverridesCmd = installation: {}: '' # Update overrides that are managed by this module (both old and new) ${pkgs.coreutils}/bin/mkdir -p ${overridesDir} @@ -81,7 +121,7 @@ let '$new.overrides + $old.overrides | keys[]' \ | while read -r APP_ID; do OVERRIDES_PATH=${overridesDir}/$APP_ID - + # Transform the INI-like Flatpak overrides file into a workable JSON if [[ -f $OVERRIDES_PATH ]]; then ACTIVE=$(${pkgs.coreutils}/bin/cat $OVERRIDES_PATH \ @@ -102,15 +142,31 @@ let done ''; - flatpakInstallCmd = installation: update: { appId, origin ? "flathub", commit ? null, ... }: '' - ${pkgs.flatpak}/bin/flatpak --${installation} --noninteractive --no-auto-pin install \ - ${if update && commit == null then ''--or-update'' else ''''} ${origin} ${appId} + flatpakCmdBuilder = installation: action: args: + "${pkgs.flatpak}/bin/flatpak --${installation} --noninteractive ${args} ${action} "; - ${if commit == null - then '' '' - else ''${pkgs.flatpak}/bin/flatpak --${installation} update --noninteractive --commit="${commit}" ${appId} - ''} - ''; + installCmdBuilder = installation: update: appId: flatpakref: origin: + flatpakCmdBuilder installation " install " + (if update then " --or-update " else " ") + + (if utils.isFlatpakref { flatpakref = flatpakref; } + then getAppIdOrRef flatpakref installation # If the appId is a flatpakref URL, get the appId from the flatpakref file + else " ${origin} ${appId} "); + + updateCmdBuilder = installation: commit: appId: + flatpakCmdBuilder installation "update" + "--no-auto-pin --commit=\"${commit}\" ${appId}"; + + flatpakInstallCmd = installation: update: { appId, origin ? "flathub", commit ? null, flatpakref ? null, ... }: + let + installCmd = installCmdBuilder installation update appId flatpakref origin; + + # pin the commit if it is provided + pinCommitOrUpdate = + if commit != null + then updateCmdBuilder installation commit appId + else ""; + in + installCmd + "\n" + pinCommitOrUpdate; flatpakAddRemotesCmd = installation: { name, location, args ? null, ... }: '' ${pkgs.flatpak}/bin/flatpak remote-add --${installation} --if-not-exists ${if args == null then "" else args} ${name} ${location} @@ -118,15 +174,15 @@ let flatpakAddRemote = installation: remotes: map (flatpakAddRemotesCmd installation) remotes; flatpakDeleteRemotesCmd = installation: {}: '' - # Delete all remotes that are present in the old state but not the new one - # $OLD_STATE and $NEW_STATE are globals, declared in the output of pkgs.writeShellScript. - ${pkgs.jq}/bin/jq -r -n \ - --argjson old "$OLD_STATE" \ - --argjson new "$NEW_STATE" \ - '(($old.remotes // []) - ($new.remotes // []))[]' \ - | while read -r REMOTE_NAME; do - ${pkgs.flatpak}/bin/flatpak remote-delete --${installation} $REMOTE_NAME - done + # Delete all remotes that are present in the old state but not the new one + # $OLD_STATE and $NEW_STATE are globals, declared in the output of pkgs.writeShellScript. + ${pkgs.jq}/bin/jq -r -n \ + --argjson old "$OLD_STATE" \ + --argjson new "$NEW_STATE" \ + '(($old.remotes // []) - ($new.remotes // []))[]' \ + | while read -r REMOTE_NAME; do + ${pkgs.flatpak}/bin/flatpak remote-delete --${installation} $REMOTE_NAME + done ''; diff --git a/modules/options.nix b/modules/options.nix index 54d4aad..6efcc55 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -40,6 +40,17 @@ let default = "flathub"; description = lib.mdDoc "App repository origin (default: flathub)."; }; + + flatpakref = mkOption { + type = types.nullOr types.str; + description = lib.mdDoc "The flakeref URI of the app to install. "; + default = null; + }; + sha256 = mkOption { + type = types.nullOr types.str; + description = lib.mdDoc "The sha256 hash of the URI to install. "; + default = null; + }; }; }; diff --git a/modules/ref.nix b/modules/ref.nix new file mode 100644 index 0000000..936d9e4 --- /dev/null +++ b/modules/ref.nix @@ -0,0 +1,63 @@ +# utiliy function to manage flatpakref files +{ lib, ... }: +let + # check if a value is a string + isString = value: builtins.typeOf value == "string"; + + # Check if a package declares a flatpakref + isFlatpakref = { flatpakref ? null, ... }: + flatpakref != null && isString flatpakref; + + # sanitize a URL to be used as a key in an attrset. + sanitizeUrl = url: builtins.replaceStrings [ "https://" "/" "." ":" ] [ "https_" "_" "_" "_" ] url; + + # Extract the remote name from a package that declares a flatpakref: + # 1. if the package sets an origin, use that as label for the remote url. + # 2. if the package does not set an origin, use the remote name suggested by the flatpakref. + # 3. if the package does not set an origin and the flatpakref does not suggest a remote name, sanitize application Name. + getRemoteNameFromFlatpakref = origin: cache: + let + remoteName = origin; + in + if remoteName == null + then + let + flatpakrefdName = + if builtins.hasAttr "SuggestRemoteName" cache + then cache.SuggestRemoteName + else "${lib.toLower cache.Name}-origin"; + in + flatpakrefdName + else + remoteName; + + # Fetch and convert an ini-like flatpakref file into an attrset, and cache it for future use + # within the same activation. + # We piggyback on builtins.fetchurl to fetch and cache flatpakref file. Pure nix evaluations + # requrie a sha256 hash to be provided. + # TODO: extract a generic ini-to-attrset function. + flatpakrefToAttrSet = { flatpakref, sha256, ... }: cache: + let + updatedCache = + if builtins.hasAttr (sanitizeUrl flatpakref) cache then + cache + else + let + fetchurlArgs = + if sha256 != null + then { url = flatpakref; sha256 = sha256; } + else { url = flatpakref; }; + iniContent = builtins.readFile (builtins.fetchurl fetchurlArgs); + lines = builtins.split "\r?\n" iniContent; + parsed = builtins.filter (line: line != null) (map (line: builtins.match "(.*)=(.*)" (builtins.toString line)) lines); + + # Convert the list of key-value pairs into an attrset + attrSet = builtins.listToAttrs (map (pair: { name = builtins.elemAt pair 0; value = builtins.elemAt pair 1; }) parsed); + in + cache // { ${(sanitizeUrl flatpakref)} = attrSet; }; + in + updatedCache; +in +{ + inherit isFlatpakref sanitizeUrl flatpakrefToAttrSet getRemoteNameFromFlatpakref; +} diff --git a/tests/fixtures/package.flatpakref b/tests/fixtures/package.flatpakref new file mode 100644 index 0000000..2d3a6f4 --- /dev/null +++ b/tests/fixtures/package.flatpakref @@ -0,0 +1,8 @@ +[Flatpak Ref] +Title=gedit +Name=org.gnome.gedit +Branch=stable +Url=http://sdk.gnome.org/repo-apps/ +IsRuntime=false +GPGKey=REDACTED +DeployCollectionID=org.gnome.Apps diff --git a/tests/ref-test.nix b/tests/ref-test.nix new file mode 100644 index 0000000..5622767 --- /dev/null +++ b/tests/ref-test.nix @@ -0,0 +1,68 @@ +{ pkgs ? import { } }: + +let + inherit (pkgs) lib; + inherit (lib) runTests; + ref = import ../modules/ref.nix { inherit lib; }; + + pwd = builtins.getEnv "PWD"; + fixturePath = "file://${pwd}/fixtures/package.flatpakref"; + fixtureHash = "040iig2yg2i28s5xc9cvp5syaaqq165idy3nhlpv8xn4f6zh4h1f"; + expectedFixtureAttrSet = { + ${ref.sanitizeUrl fixturePath} = { + Title = "gedit"; + Name = "org.gnome.gedit"; + Branch = "stable"; + Url = "http://sdk.gnome.org/repo-apps/"; + IsRuntime = "false"; + GPGKey = "REDACTED"; + DeployCollectionID = "org.gnome.Apps"; + }; + }; +in +runTests { + testSanitizeUrl = { + expr = ref.sanitizeUrl "https://example.local"; + expected = "https_example_local"; + }; + + testIsFlatpakref = { + expr = ref.isFlatpakref { flatpakref = "https://example.local/package.flatpakref"; }; + expected = true; + }; + + testIsFlatpakrefWithNull = { + expr = ref.isFlatpakref { flatpakref = null; }; + expected = false; + }; + + testIsFlatpakrefWithMissing = { + expr = ref.isFlatpakref { appId = "local.example.Package"; }; + expected = false; + }; + + testGetRemoteNameWithOrigin = { + expr = ref.getRemoteNameFromFlatpakref "example" { SuggestRemoteName = "local"; }; + expected = "example"; + }; + + testGetRemoteNameWithSuggestedName = { + expr = ref.getRemoteNameFromFlatpakref null { SuggestRemoteName = "local"; }; + expected = "local"; + }; + + testGetRemoteNameWithPackageName = { + expr = ref.getRemoteNameFromFlatpakref null { Name = "Example"; }; + expected = "example-origin"; + }; + + testFlatpakrefToAttrSet = { + expr = ref.flatpakrefToAttrSet { flatpakref = fixturePath; sha256 = null; } { }; + expected = expectedFixtureAttrSet; + }; + + testFlatpakrefToAttrSetWithSha256 = { + expr = ref.flatpakrefToAttrSet { flatpakref = fixturePath; sha256 = fixtureHash; } { }; + expected = expectedFixtureAttrSet; + }; +}