Skip to content

Commit

Permalink
installer: add support for flatpakref. (#80)
Browse files Browse the repository at this point in the history
Adds support for installing application from a
flatpakref uri.
  • Loading branch information
gmodena authored Oct 15, 2024
1 parent d65dbf5 commit 8d1193a
Show file tree
Hide file tree
Showing 7 changed files with 252 additions and 21 deletions.
9 changes: 9 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,22 @@ You can pin a specific commit setting `commit=<hash>` 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 = "<uri>"; sha256="<hash>"; }
];
```

A `sha256` hash is required for the flatpakref file. This can be generated with `nix-prefetch-url <uri>`.
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
Expand Down
98 changes: 77 additions & 21 deletions modules/installer.nix
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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}";
Expand All @@ -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 \
Expand All @@ -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}
Expand All @@ -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 \
Expand All @@ -102,31 +142,47 @@ 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}
'';
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
'';


Expand Down
11 changes: 11 additions & 0 deletions modules/options.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
};
};

Expand Down
63 changes: 63 additions & 0 deletions modules/ref.nix
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 8 additions & 0 deletions tests/fixtures/package.flatpakref
Original file line number Diff line number Diff line change
@@ -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
68 changes: 68 additions & 0 deletions tests/ref-test.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{ pkgs ? import <nixpkgs> { } }:

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;
};
}

0 comments on commit 8d1193a

Please sign in to comment.