Skip to content

Commit

Permalink
installer: minimize side effects on activation (#132)
Browse files Browse the repository at this point in the history
Refactor activation logic to evaluate the system's flatpak
configuration and determine whether `flatpak install` commands
are required. Avoids unnecessary reinstallation and network
calls during activation, improving support for "offline" environments.

Introduces a new state module to manage activation-related state.

Adds helpers to process `flatpak-state.json` with Nix expressions.

Note: For packages pinned to a specific `commit` hash,
a remote query is still required to verify the reference,
resulting in a network call.
  • Loading branch information
gmodena authored Jan 7, 2025
1 parent 78ed84f commit 11eb05a
Show file tree
Hide file tree
Showing 5 changed files with 207 additions and 8 deletions.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,12 @@ Package overrides can be declared via `services.flatpak.overrides`. Following is

A couple of things to be aware of when working with `nix-flatpak`.

## Installation on Activation: Side Effects

During activation, `nix-flatpak` evaluates the system's flatpak configuration to determine whether flatpak install commands need to be executed. Typically, re-installation of Flatpak packages is avoided, unless the user has enabled `onActivation` or `auto` package updates.

However, due to current [implementation limitations](https://github.com/gmodena/nix-flatpak/issues/85) (as of January 2025), if a package is pinned to a specific commit hash, nix-flatpak will query the remote repository to verify the reference. This introduces a network call, even though the Flatpak package itself will not be re-downloaded. As a result, this behavior disrupts "offline" activations.

## Infinite recursion in home-manager imports

Users have reported an infinite recursion stacktrace when importing an home-manager module outside of where home-manager
Expand Down
61 changes: 53 additions & 8 deletions modules/installer.nix
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
let
utils = import ./ref.nix { inherit lib; };
remotes = import ./remotes.nix { inherit pkgs; };
state = import ./state.nix { inherit pkgs; };

flatpakrefCache = builtins.foldl'
(acc: package:
Expand Down Expand Up @@ -75,6 +76,9 @@ let

statePath = "${gcroots}/${stateFile.name}";

# cache the old state. We need this to manipulate the state from nix expression.
stateData = state.readState stateFile;

updateApplications = cfg.update.onActivation || cfg.update.auto.enable;

# This script is used to manage the lifecyle of all flatpaks (remotes, packages)
Expand Down Expand Up @@ -161,6 +165,8 @@ let
flatpakCmdBuilder = installation: action: args:
"${pkgs.flatpak}/bin/flatpak --${installation} --noninteractive ${args} ${action} ";

# TODO
# - don't attempt an installation if appId is present in OLD_STATE
installCmdBuilder = installation: update: appId: flatpakref: origin:
flatpakCmdBuilder installation " install "
(if update then " --or-update " else " ") +
Expand All @@ -172,17 +178,56 @@ let
flatpakCmdBuilder installation "update"
"--no-auto-pin --commit=\"${commit}\" ${appId}";

# Generates a shell command to install or update a Flatpak application based on
# various conditions. This command will either perform a new installation, update
# to a specific commit, or skip if the application is already installed.
#
# Example:
# flatpakInstallCmd "user" false {
# appId = "local.test.App";
# commit = "abc123";
# }
#
# Arguments:
# installation The Flatpak installation type (e.g., 'system' or 'user')
# update Boolean flag to force update of existing installations
# appId The Flatpak application ID to install
# origin (optional) The Flatpak repository origin (default: "flathub")
# commit (optional) Specific commit hash to pin the installation to
# flatpakref (optional) Path to a .flatpakref file
#
# This function relies on state.shouldExecFlatpakInstall to determine if
# installation is needed for the given parameters.
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 "";
# Install if:
# - update flag is true OR
# - app is not installed OR
# - commit is specified and doesn't match current
shouldInstall = state.shouldExecFlatpakInstall stateData installation update appId commit;

installCmd =
if shouldInstall
then
# pin the commit if it is provided
let
pinCommitOrUpdate =
if commit != null
then updateCmdBuilder installation commit appId
else "";
in
# To install at a specific commit hash we need to first install the appId,
# then update to the pinned commit id.
''
${installCmdBuilder installation update appId flatpakref origin}
${pinCommitOrUpdate}
''
else
''
# ${appId} is already installed. Skipping.
'';
in
installCmd + "\n" + pinCommitOrUpdate;
installCmd;

flatpakInstall = installation: update: packages: map (flatpakInstallCmd installation update) packages;

Expand Down
81 changes: 81 additions & 0 deletions modules/state.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Helper functions for managing nix-flatpak state.
# Historically, nix-flatpak relied on `jq` to read, parse, and process
# state files. Over time, the codebase is being refactored to
# favor using Nix expressions instead.
{ pkgs, ... }:
let

# Reads and parses a JSON state file.
# Takes a file path, and converts it from JSON format
# to a Nix-compatible data structure using built-in functions.
#
# Parameters:
# stateFile: file path
#
# Returns
# attrset: nix representation of the nix-flatpak state
#
readState = stateFile:
builtins.fromJSON (builtins.readFile (builtins.toString stateFile));


# TODO: Checks if a Flatpak app's current commit matches an expected commit hash
#
# Parameters:
# installation: type of Flatpak installation (user, system)
# appId: flatpak application id (e.g., org.mozilla.firefox)
# commit: expected commit hash to check against, or null to skip check
#
# Returns:
# boolean: True if either:
# - commit parameter is null (skip check)
# - current installed commit matches expected commit
# False otherwise
#
checkCommitMatch = installation: appId: commit:
# we don't store commit into in flatpak-state.json,
# and checks during Nix evaluation is tricky.
# If a `commit` is provided, assume it does not match
# the currently installed one, and force an update. In practice,
# the application won't be re-donwloaded, but its ref will be looked up
# in the remote.
# FIXME: https://github.com/gmodena/nix-flatpak/issues/85
commit == null || false;

# Determines if flatpak install command should be executed based on system state
#
# Parameters:
# installation: Path to Flatpak installation
# update: Boolean flag to force update
# appId: Flatpak application ID
# commit: Expected commit hash or null
#
# Returns:
# boolean: True if any of:
# - update flag is true
# - app is not installed
# - commit is specified and doesn't match current
#
shouldExecFlatpakInstall = stateData: installation: update: appId: commit:
let
# Currently (2024-12) we don't store the commit hash pin in nix-flatpak state.
isInstalled = builtins.elem appId stateData."packages";

# Verify commit hash matches if app is installed and a commit is pinned.
commitMatches =
if isInstalled
then checkCommitMatch installation appId commit
else false;

# Run `flatpak install` if:
# - update flag is true OR
# - app is not installed OR
# - commit is specified and doesn't match current
shouldInstall = update || !isInstalled || (commit != null && !commitMatches);
in
shouldInstall;

in
{
inherit readState shouldExecFlatpakInstall;
}
10 changes: 10 additions & 0 deletions tests/fixtures/flatpak-state.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"overrides": {},
"packages": [
"im.riot.Riot"
],
"remotes": [
"flathub"
]
}

57 changes: 57 additions & 0 deletions tests/state-test.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{ pkgs ? import <nixpkgs> { } }:

let
inherit (pkgs) lib;
inherit (lib) runTests;
state = import ../modules/state.nix { inherit pkgs; };

appId = "im.riot.Riot";

pwd = builtins.getEnv "PWD";
stateData = state.readState ("${pwd}/fixtures/flatpak-state.json");

in
runTests {
testShoulNotExectFlatpakInstall = {
# Base case: state matches runtime. We don't need to `flatpak install` apps.
# installation = "user";
# update = false;
# commit = null;

expr = state.shouldExecFlatpakInstall stateData "user" false appId null;
expected = false;
};

testShoulExectFlatpakInstallWhenUpdate = {
# Apps need to be updated on activation.
# installation = "user";
# update = true;
# commit = null;

expr = state.shouldExecFlatpakInstall stateData "user" true appId null;
expected = true;
};

testShoulExectFlatpakInstallWhenCommit = {
# Apps need to be pinned at `commit`. Currently, this requires an
# update on activation.
# installation = "user";
# update = false;
# commit = "1234";

expr = state.shouldExecFlatpakInstall stateData "user" false appId "1234";
expected = true;
};

testShoulExectFlatpakInstallOnNewApp = {
# state has mutate: a new app as been added, and must be installed on activation.
# update on activation.
# appId = "io.github.gmodena.NewApp"
# installation = "user";
# update = false;
# commit = null;

expr = state.shouldExecFlatpakInstall stateData "user" false "io.github.gmodena.NewApp" null;
expected = true;
};
}

0 comments on commit 11eb05a

Please sign in to comment.