From 46d33751c687562fd2061354b016b52bb92d60a8 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Wed, 30 Sep 2026 05:22:26 +0100 Subject: [PATCH] Back up portal configuration during explicit development upgrades --- docs/installation.md | 6 +++ scripts/install-session.sh | 41 ++++++++++++--- scripts/install.sh | 4 ++ scripts/tests/test_portal_install.py | 76 +++++++++++++++++++++++++++- 4 files changed, 119 insertions(+), 8 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index b7c1c0e..c57e45e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -267,6 +267,12 @@ git pull --ff-only Log out and back in to use the new release. Running sessions keep their existing processes and release paths until restarted. +The installer records shipped portal configuration with each release for future +upgrades. Modified or untracked administrator files stop installation. To replace +Ferese portal configuration from an earlier development install, use +`./scripts/install.sh --skip-build --replace-portal-config` after building. Existing +files are backed up under the new release's `portal-config.previous/` directory. + Installer options: ```sh diff --git a/scripts/install-session.sh b/scripts/install-session.sh index f5e5127..2af17ed 100644 --- a/scripts/install-session.sh +++ b/scripts/install-session.sh @@ -8,8 +8,21 @@ session_entry_matches() { cmp -s -- <(sed '/^Icon=/d' "$1") <(sed '/^Icon=/d' "$2") } +managed_file_matches() { + local relative=$1 destination=$2 + cmp -s -- "$repo_dir/packaging/$relative" "$destination" || + cmp -s -- "$install_root/current/installer-files/$relative" "$destination" +} + repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) -release_id=${1:?usage: install-session.sh RELEASE_ID} +release_id=${1:?usage: install-session.sh RELEASE_ID [--replace-portal-config]} +replace_portal_config=false +if [[ ${2:-} == --replace-portal-config && $# == 2 ]]; then + replace_portal_config=true +elif (($# > 1)); then + echo 'usage: install-session.sh RELEASE_ID [--replace-portal-config]' >&2 + exit 2 +fi [[ $release_id =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || exit 2 [[ $EUID == 0 ]] || { echo 'Run this installer with sudo.' >&2; exit 1; } @@ -64,12 +77,14 @@ for entry in \ 'ferese-portals.conf:ferese-portals.conf'; do source=${entry%%:*} destination=/usr/share/xdg-desktop-portal/${entry#*:} - if [[ -e $destination ]] && ! cmp -s "$repo_dir/packaging/portal/$source" "$destination"; then - echo "Unmanaged portal configuration exists: $destination" >&2; exit 1 + if [[ -e $destination ]] && ! managed_file_matches "portal/$source" "$destination" && ! $replace_portal_config; then + echo "Unmanaged portal configuration exists: $destination" >&2 + echo 'Use --replace-portal-config to back up and replace Ferese portal configuration.' >&2 + exit 1 fi done service=/usr/local/share/dbus-1/services/org.freedesktop.impl.portal.desktop.ferese.service -if [[ -e $service ]] && ! cmp -s "$repo_dir/packaging/portal/$(basename "$service")" "$service"; then +if [[ -e $service ]] && ! managed_file_matches "portal/$(basename "$service")" "$service"; then # Upgrade the exact activation file shipped before systemd supervision. if ! cmp -s <(printf '%s\n' '[D-BUS Service]' \ 'Name=org.freedesktop.impl.portal.desktop.ferese' \ @@ -79,12 +94,12 @@ if [[ -e $service ]] && ! cmp -s "$repo_dir/packaging/portal/$(basename "$servic fi portal_unit=/usr/local/lib/systemd/user/xdg-desktop-portal-ferese.service -if [[ -e $portal_unit ]] && ! cmp -s "$repo_dir/packaging/systemd/xdg-desktop-portal-ferese.service" "$portal_unit"; then +if [[ -e $portal_unit ]] && ! managed_file_matches "systemd/xdg-desktop-portal-ferese.service" "$portal_unit"; then echo "Unmanaged portal unit exists: $portal_unit" >&2; exit 1 fi session_target=/usr/local/lib/systemd/user/ferese-session.target -if [[ -e $session_target ]] && ! cmp -s "$repo_dir/packaging/systemd/ferese-session.target" "$session_target"; then +if [[ -e $session_target ]] && ! managed_file_matches "systemd/ferese-session.target" "$session_target"; then echo "Unmanaged session target exists: $session_target" >&2; exit 1 fi @@ -92,6 +107,20 @@ desktop-file-validate "$repo_dir/packaging/ferese.desktop" desktop-file-validate "$repo_dir/packaging/dev.ferese.Settings.desktop" install -d -m 0755 -- "$release_dir" /usr/local/bin /usr/share/wayland-sessions +for relative in portals/ferese.portal ferese-portals.conf; do + destination=/usr/share/xdg-desktop-portal/$relative + if [[ -e $destination ]]; then + install -D -m 0644 -- "$destination" "$release_dir/portal-config.previous/$relative" + fi +done +for relative in \ + portal/ferese.portal \ + portal/ferese-portals.conf \ + portal/org.freedesktop.impl.portal.desktop.ferese.service \ + systemd/xdg-desktop-portal-ferese.service \ + systemd/ferese-session.target; do + install -D -m 0644 -- "$repo_dir/packaging/$relative" "$release_dir/installer-files/$relative" +done if [[ -e $session_entry ]]; then install -m 0644 -- "$session_entry" "$release_dir/session.previous.desktop" fi diff --git a/scripts/install.sh b/scripts/install.sh index c199f76..3c16577 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -11,6 +11,7 @@ Run from any directory as your normal user. Existing user config is preserved. --release-id ID Name the installed release (default: UTC timestamp + PID) --skip-build Install existing target/release binaries + --replace-portal-config Back up and replace existing Ferese portal configuration --offline Build using only cached Cargo dependencies --dry-run Print commands without building or installing -h, --help Show this help @@ -24,6 +25,7 @@ fail() { echo "ferese installer: $*" >&2; exit 1; } repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) release_id="$(date -u +%Y%m%d-%H%M%S)-$$" skip_build=false +replace_portal_config=false offline=false dry_run=false while (($#)); do @@ -34,6 +36,7 @@ while (($#)); do shift 2 ;; --skip-build) skip_build=true; shift ;; + --replace-portal-config) replace_portal_config=true; shift ;; --offline) offline=true; shift ;; --dry-run) dry_run=true; shift ;; -h|--help) usage; exit 0 ;; @@ -57,6 +60,7 @@ if ! $skip_build; then fi installer=(bash "$repo_dir/scripts/install-session.sh" "$release_id") +if $replace_portal_config; then installer+=(--replace-portal-config); fi if ((EUID != 0)); then if command -v sudo >/dev/null; then installer=(sudo "${installer[@]}") diff --git a/scripts/tests/test_portal_install.py b/scripts/tests/test_portal_install.py index 52906a4..1a70129 100644 --- a/scripts/tests/test_portal_install.py +++ b/scripts/tests/test_portal_install.py @@ -14,7 +14,8 @@ class PortalInstallTest(unittest.TestCase): def preflight(self, service=None, unit=None): script = (REPO / 'scripts/install-session.sh').read_text() start = script.index('service=/usr/local/share/dbus-1/services/') - script = script[start:script.index('session_target=', start)] + helpers = script[:script.index('repo_dir=')] + script = helpers + script[start:script.index('session_target=', start)] with tempfile.TemporaryDirectory() as directory: root = Path(directory) if service is not None: @@ -30,9 +31,80 @@ class PortalInstallTest(unittest.TestCase): 'portal_unit=/usr/local/lib/systemd/user/xdg-desktop-portal-ferese.service', 'portal_unit="$TEST_ROOT/portal.service"') return subprocess.run(['bash', '-eu', '-c', script], - env={'repo_dir': str(REPO), 'TEST_ROOT': directory}, + env={'repo_dir': str(REPO), 'TEST_ROOT': directory, 'install_root': directory}, capture_output=True, text=True) + def configuration_preflight(self, portal=None, preferences=None, previous=None, replace=False): + script = (REPO / 'scripts/install-session.sh').read_text() + helpers = script[:script.index('repo_dir=')] + start = script.index('for entry in ') + check = script[start:script.index('service=/usr/local/share/dbus-1/services/', start)] + check = check.replace('/usr/share/xdg-desktop-portal/', '$TEST_ROOT/config/') + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for relative, content in [('portals/ferese.portal', portal), ('ferese-portals.conf', preferences)]: + if content is not None: + destination = root / 'config' / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(content) + if previous: + for relative, content in previous.items(): + destination = root / 'current/installer-files/portal' / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(content) + return subprocess.run(['bash', '-eu', '-c', helpers + check], + env={'repo_dir': str(REPO), 'TEST_ROOT': directory, 'install_root': directory, + 'replace_portal_config': str(replace).lower()}, + capture_output=True, text=True) + + def test_existing_development_install_requires_explicit_replacement(self): + portal = ('[portal]\nDBusName=org.freedesktop.impl.portal.desktop.ferese\n' + 'Interfaces=org.freedesktop.impl.portal.ScreenCast;\nUseIn=Ferese;\n') + preferences = '[preferred]\ndefault=gtk;\norg.freedesktop.impl.portal.ScreenCast=ferese;\n' + result = self.configuration_preflight(portal, preferences) + self.assertNotEqual(result.returncode, 0) + self.assertIn('--replace-portal-config', result.stderr) + result = self.configuration_preflight(portal, preferences, replace=True) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_previous_portal_configuration_is_backed_up(self): + script = (REPO / 'scripts/install-session.sh').read_text() + start = script.index('for relative in portals/ferese.portal') + backup = script[start:script.index('for relative in ', start + 1)] + backup = backup.replace('/usr/share/xdg-desktop-portal/', '$TEST_ROOT/config/') + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for relative in ('portals/ferese.portal', 'ferese-portals.conf'): + source = root / 'config' / relative + source.parent.mkdir(parents=True, exist_ok=True) + source.write_text('existing custom content\n') + result = subprocess.run(['bash', '-eu', '-c', backup], + env={'TEST_ROOT': directory, 'release_dir': str(root / 'release')}, + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + for relative in ('portals/ferese.portal', 'ferese-portals.conf'): + self.assertEqual((root / 'release/portal-config.previous' / relative).read_text(), + 'existing custom content\n') + self.assertEqual((root / 'config' / relative).read_text(), 'existing custom content\n') + + def test_upgrades_recorded_previous_release(self): + old_portal = '[portal]\nInterfaces=org.freedesktop.impl.portal.Settings;\n' + old_preferences = '[preferred]\norg.freedesktop.impl.portal.Settings=ferese;\n' + result = self.configuration_preflight(old_portal, old_preferences, + {'ferese.portal': old_portal, 'ferese-portals.conf': old_preferences}) + self.assertEqual(result.returncode, 0, result.stderr) + result = self.configuration_preflight(old_portal + 'UseIn=Custom;\n', old_preferences, + {'ferese.portal': old_portal, 'ferese-portals.conf': old_preferences}) + self.assertNotEqual(result.returncode, 0) + self.assertIn('Unmanaged portal configuration', result.stderr) + + def test_preserves_custom_configuration(self): + portal = (REPO / 'packaging/portal/ferese.portal').read_text() + preferences = '[preferred]\ndefault=custom;\n' + result = self.configuration_preflight(portal, preferences) + self.assertNotEqual(result.returncode, 0) + self.assertIn('Unmanaged portal configuration', result.stderr) + def test_accepts_fresh_install_and_previous_activation_file(self): self.assertEqual(self.preflight().returncode, 0) self.assertEqual(self.preflight(LEGACY).returncode, 0)