Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,3 +361,27 @@ have no semibold) instead of the app's embedded Noto fonts.
against `MemoryRegistry`, a fake `ISetupSystem` and scratch folders, and renders every page. `/SANDBOX=<folder>`
runs the real Setup with a registry file and all folders inside `<folder>`; `build-installer.ps1 -CheckOnly` (CI)
installs, updates and uninstalls that way.

## Linux installer and updates

On Linux, Filee is distributed as a self-contained archive (`Filee-<version>-<rid>.tar.gz` for `linux-x64` and
`linux-arm64`) containing the application, `filee-cli`, bundled conversion engines, application icon, `.desktop` launcher,
and standalone shell scripts (`install.sh`, `uninstall.sh`).

- **Installation**: `install.sh` installs for the current user into `~/.local/share/filee/application` without root,
creating a launcher symlink at `~/.local/bin/filee` and desktop registration at
`~/.local/share/applications/com.filee.app.desktop`. A `.filee-package` marker containing `com.filee.app` identifies
the directory as an authentic managed installation. Running without `--upgrade` refuses to overwrite an existing
installation.
- **In-app updates** (`UpdateService`): Checks GitHub releases for matching `<rid>.tar.gz` and `<rid>-SHA256SUMS.txt`
assets. The tarball is verified against SHA-256 before extraction. Archive extraction validates that all entries stay
within the target directory, rejecting absolute paths, directory symlinks, and escaping links, and masks permissions to
standard rwx bits (excluding setuid/setgid/sticky bits).
- **Transactional upgrade**: `UpdateAsync` stages the payload in a private directory (`~/.local/share/filee/.update-*`),
spawns a detached `/bin/sh` helper, and requests application shutdown (`App.Quit`). Once the old process exits, the
helper executes `install.sh --upgrade` (which safely moves the existing installation to a backup directory, validates the
new files, and rolls back on failure or signal interruption), and restarts Filee with `--background --whats-new`.
Output and rollback diagnostics are appended to Filee's daily log (`~/.config/Filee/logs/`). Downloaded conversion engines
(`~/.local/share/Filee/engines`) and user settings (`~/.config/Filee`) remain untouched.
- **Uninstallation**: `uninstall.sh` removes the application directory, desktop launcher, and `~/.local/bin/filee` symlink
after verifying the `.filee-package` marker. User settings and converted files are kept.
20 changes: 19 additions & 1 deletion installer/linux/INSTALL.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,28 @@ Run ./Filee to test the extracted app, or ./filee-cli help for the command line.

Run ./install.sh to install for the current user. It requires Python 3, creates
an application launcher and ~/.local/bin/filee, and does not require root.
Run ./install.sh --upgrade to safely upgrade an existing installation in place
without uninstalling. Your settings, presets, history, and downloaded conversion
engines are kept. Running without --upgrade refuses to overwrite an existing installation.
Run ./uninstall.sh to remove this installation and Filee-downloaded engines.
Your settings and converted files are kept. If integration cleanup fails, the
installation is kept so you can fix the error and run the uninstaller again.
An existing installation is never overwritten; uninstall it before replacing it.

Upgrade reliability and crash recovery:
The upgrade procedure prepares files on the same filesystem and replaces the
installation using atomic rename steps with an automatic rollback trap. If an error
or signal interruption occurs during the swap, the previous version is restored.
Note: While each individual directory rename is atomic in POSIX, the two-step swap
(moving the old installation aside, then moving the new version into place) is not
a single atomic transaction. In the event of uncatchable termination (SIGKILL) or
sudden hardware power loss during the brief window between steps, automatic traps
cannot execute; the previous installation is safely preserved in .backup-<pid>.
To recover manually:
- If the application directory does not exist:
mv ~/.local/share/filee/.backup-<pid> ~/.local/share/filee/application
- If a partial or invalid application directory already exists:
Inspect and remove or rename ~/.local/share/filee/application first, then run:
mv ~/.local/share/filee/.backup-<pid> ~/.local/share/filee/application

Filee runs on Ubuntu 22.04 or newer and other distributions with glibc 2.35 or
newer. The .NET runtime is bundled; its native system libraries are still
Expand Down
236 changes: 219 additions & 17 deletions installer/linux/install.sh
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,30 +1,226 @@
#!/bin/sh
set -eu

source_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
# Filee Linux standalone installer.
# Supports first-time installation and in-place upgrade (--upgrade).
# Preserves downloaded conversion engines (~/.local/share/Filee/engines) and user settings (~/.config/Filee).

upgrade=0

for arg do
case "$arg" in
--upgrade|-u)
upgrade=1
;;
--help|-h)
printf '%s\n' "Usage: $0 [--upgrade|-u]"
exit 0
;;
*)
printf '%s\n' "Unknown option: $arg" >&2
printf '%s\n' "Usage: $0 [--upgrade|-u]" >&2
exit 1
;;
esac
done

source_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)
data_dir=${XDG_DATA_HOME:-"$HOME/.local/share"}
install_dir="$data_dir/filee/application"
bin_dir="$HOME/.local/bin"
desktop_file="$data_dir/applications/com.filee.app.desktop"
if [ -e "$install_dir" ] || [ -L "$install_dir" ]; then
printf '%s\n' "An installation already exists at $install_dir. Run its uninstall.sh first."

# Validate required tools and incoming payload files before doing anything.
command -v python3 >/dev/null 2>&1 || {
printf '%s\n' "Python 3 is required to install Filee." >&2
exit 1
fi
if [ -e "$bin_dir/filee" ] || [ -L "$bin_dir/filee" ]; then
printf '%s\n' "$bin_dir/filee already exists. Remove or rename it before installing Filee."
}


if [ ! -f "$source_dir/Filee" ] || [ ! -x "$source_dir/Filee" ]; then
printf '%s\n' "Payload validation failed: $source_dir/Filee is missing or not executable." >&2
exit 1
fi
if [ -e "$desktop_file" ] || [ -L "$desktop_file" ]; then
printf '%s\n' "$desktop_file already exists. Remove or rename it before installing Filee."

if [ ! -f "$source_dir/filee-cli" ] || [ ! -x "$source_dir/filee-cli" ]; then
printf '%s\n' "Payload validation failed: $source_dir/filee-cli is missing or not executable." >&2
exit 1
fi
command -v python3 >/dev/null
test -x "$source_dir/Filee"
test -x "$source_dir/filee-cli"
mkdir -p "$data_dir/filee" "$bin_dir" "$data_dir/applications"
cp -R "$source_dir" "$install_dir"
printf '%s\n' 'com.filee.app' > "$install_dir/.filee-package"
ln -s "$install_dir/filee-cli" "$bin_dir/filee"

if [ "$upgrade" -eq 0 ]; then
# First-time installation: refuse to overwrite an existing installation or files.
if [ -e "$install_dir" ] || [ -L "$install_dir" ]; then
printf '%s\n' "An installation already exists at $install_dir. Run its uninstall.sh first."
exit 1
fi
if [ -e "$bin_dir/filee" ] || [ -L "$bin_dir/filee" ]; then
printf '%s\n' "$bin_dir/filee already exists. Remove or rename it before installing Filee."
exit 1
fi
if [ -e "$desktop_file" ] || [ -L "$desktop_file" ]; then
printf '%s\n' "$desktop_file already exists. Remove or rename it before installing Filee."
exit 1
fi

mkdir -p "$data_dir/filee" "$bin_dir" "$data_dir/applications"
cp -R "$source_dir" "$install_dir"
printf '%s\n' 'com.filee.app' > "$install_dir/.filee-package"
rm -f "$install_dir/.filee-upgrade-token"
ln -s "$install_dir/filee-cli" "$bin_dir/filee"
else
# Upgrade existing installation: verify it is genuine and safely swap in place.
if [ ! -d "$install_dir" ] || [ -L "$install_dir" ]; then
printf '%s\n' "No existing installation found at $install_dir to upgrade." >&2
exit 1
fi

marker_file="$install_dir/.filee-package"
if [ ! -f "$marker_file" ]; then
printf '%s\n' "Cannot upgrade: $install_dir is missing the .filee-package marker." >&2
exit 1
fi

marker=$(tr -d '\r\n' < "$marker_file" 2>/dev/null || true)
if [ "$marker" != "com.filee.app" ]; then
printf '%s\n' "Cannot upgrade: $install_dir was not installed by Filee." >&2
exit 1
fi

# Tell running instance to save and exit cleanly before touching files.
if [ -x "$install_dir/Filee" ]; then
if ! "$install_dir/Filee" --quit; then
printf '%s\n' "Could not gracefully shut down the running Filee instance. Please exit Filee and try again." >&2
exit 1
fi
fi

# Generate a unique transaction ID for this upgrade session using PID and random bytes.
# This prevents collisions with any concurrent installer or pre-existing backup artifacts.
token_rand=$(od -vN 8 -An -tx1 /dev/urandom 2>/dev/null | tr -d ' \n' || true)
tx_id="$$-${token_rand:-$(date +%s)}"
staging_dir="$data_dir/filee/.staging-$tx_id"
backup_dir="$data_dir/filee/.backup-$tx_id"
failed_dir="$data_dir/filee/.failed-$tx_id"
upgrade_token="filee-upgrade-$tx_id"
activated=0

# Ensure transaction paths are completely distinct and do not collide with pre-existing files.
if [ -e "$staging_dir" ] || [ -e "$backup_dir" ] || [ -e "$failed_dir" ]; then
printf '%s\n' "Transaction path conflict detected at $data_dir/filee. Please retry." >&2
exit 1
fi

mkdir -p "$data_dir/filee" "$bin_dir" "$data_dir/applications"
mkdir "$staging_dir"

# Copy incoming payload into staging (preserves source_dir without moving/deleting it).
cp -R "$source_dir/." "$staging_dir/"
printf '%s\n' 'com.filee.app' > "$staging_dir/.filee-package"
printf '%s\n' "$upgrade_token" > "$staging_dir/.filee-upgrade-token"
chmod +x "$staging_dir/Filee" "$staging_dir/filee-cli"

# Validate the staged files before moving the existing installation aside.
if [ ! -x "$staging_dir/Filee" ] || [ ! -x "$staging_dir/filee-cli" ]; then
rm -rf "$staging_dir"
printf '%s\n' "Staged payload validation failed." >&2
exit 1
fi

# Rollback trap: handles catchable signals and script aborts.
# Ensures $backup_dir is restored if anything fails during replacement.
rollback_and_cleanup() {
exit_code=$?
if [ -d "$backup_dir" ]; then
# If new files were activated into $install_dir, verify that $install_dir
# actually contains this run's upgrade token before moving it aside.
# This prevents another process or symlink swap from causing cleanup
# to remove anything other than this process's failed payload.
if [ "$activated" -eq 1 ] && [ -d "$install_dir" ] && [ ! -L "$install_dir" ]; then
token_file="$install_dir/.filee-upgrade-token"
if [ -f "$token_file" ] && [ ! -L "$token_file" ]; then
token=$(tr -d '\r\n' < "$token_file" 2>/dev/null || true)
if [ "$token" = "$upgrade_token" ]; then
# Token verified: safely move the failed payload aside to quarantine.
# Ensure $failed_dir does not already exist so mv performs a clean rename,
# and never fall back to recursive deletion if quarantine move fails.
if [ ! -e "$failed_dir" ] && mv "$install_dir" "$failed_dir" 2>/dev/null; then
# Re-verify token inside quarantined directory before deletion (TOCTOU mitigation).
# If ownership, symlink checks, or token match fail, keep $failed_dir intact.
q_token_file="$failed_dir/.filee-upgrade-token"
if [ -d "$failed_dir" ] && [ ! -L "$failed_dir" ] && [ -f "$q_token_file" ] && [ ! -L "$q_token_file" ]; then
q_token=$(tr -d '\r\n' < "$q_token_file" 2>/dev/null || true)
if [ "$q_token" = "$upgrade_token" ]; then
rm -rf "$failed_dir" 2>/dev/null || true
fi
fi
fi
fi
fi
fi

if [ ! -d "$install_dir" ] && mv "$backup_dir" "$install_dir" 2>/dev/null; then
printf '%s\n' "Update failed. Restored the previous Filee installation." >&2
if [ -d "$failed_dir" ]; then
printf '%s\n' "Quarantined failed update files preserved at: $failed_dir" >&2
fi
else
printf '%s\n' "CRITICAL: Update failed and automatic rollback could not complete." >&2
printf '%s\n' "Your previous installation is preserved at: $backup_dir" >&2
if [ -d "$install_dir" ]; then
printf '%s\n' "A partial or invalid directory exists at: $install_dir" >&2
printf '%s\n' "To restore manually, inspect and remove or rename '$install_dir', then run:" >&2
printf '%s\n' " mv '$backup_dir' '$install_dir'" >&2
else
printf '%s\n' "To restore manually, run: mv '$backup_dir' '$install_dir'" >&2
fi
if [ -d "$failed_dir" ]; then
printf '%s\n' "Quarantined failed update files preserved at: $failed_dir" >&2
fi
fi
fi
rm -rf "$staging_dir" 2>/dev/null || true
exit "$exit_code"
}
trap rollback_and_cleanup EXIT INT TERM HUP

# Note on atomicity and crash recovery:
# Individual filesystem renames (rename syscall) are atomic in POSIX.
# However, replacing a non-empty directory on Linux requires two renames:
# 1. mv "$install_dir" "$backup_dir"
# 2. mv "$staging_dir" "$install_dir"
# The two-step sequence as a whole is not a single atomic transaction.
# There is a brief window between step 1 and step 2 where $install_dir does
# not exist. Catchable signals (SIGINT, SIGTERM, SIGHUP) and script errors
# are handled by the rollback trap above, which immediately restores $backup_dir.
# If uncatchable termination (SIGKILL) or sudden hardware power loss occurs
# during this brief window, automatic traps cannot run; the previous installation
# remains safe in $backup_dir and can be restored manually.

# Move active installation to backup.
mv "$install_dir" "$backup_dir"

# Activate new installation from staging.
if ! mv "$staging_dir" "$install_dir"; then
exit 1
fi
activated=1

# Post-swap validation: ensure new installation is active and healthy.
if [ ! -x "$install_dir/Filee" ] || [ ! -x "$install_dir/filee-cli" ] || [ ! -f "$install_dir/.filee-package" ]; then
printf '%s\n' "Activated installation failed post-swap validation. Rolling back..." >&2
exit 1
fi

# Success: disarm the rollback trap before removing the old version backup.
trap - EXIT INT TERM HUP
rm -rf "$backup_dir"
rm -f "$install_dir/.filee-upgrade-token"

# Refresh launcher symlink (using -f to overwrite existing link atomically).
ln -sf "$install_dir/filee-cli" "$bin_dir/filee"
fi

# Update desktop launcher.
python3 - "$install_dir" "$desktop_file" <<'PY'
import pathlib
import sys
Expand All @@ -46,7 +242,13 @@ desktop.write_text(
f'Icon={desktop_value(app / "filee.png")}\n'
'Terminal=false\nCategories=Utility;\nX-Filee-Managed=true\n', encoding='utf-8')
PY

if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database "$data_dir/applications"
update-desktop-database "$data_dir/applications" 2>/dev/null || true
fi

if [ "$upgrade" -eq 1 ]; then
printf '%s\n' "Upgraded Filee at $install_dir."
else
printf '%s\n' "Installed Filee at $install_dir" "CLI: $bin_dir/filee (add $bin_dir to PATH if needed)."
fi
printf '%s\n' "Installed Filee at $install_dir" "CLI: $bin_dir/filee (add $bin_dir to PATH if needed)."
7 changes: 7 additions & 0 deletions src/Filee.App/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,9 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop)
_ = Task.Run(() => Filee.Core.Web.WebDrop.DeleteStale(Filee.Core.Web.WebDrop.DefaultWorkRoot, TimeSpan.FromDays(1)));
// Likewise the work folders of conversions that never got to clean up after themselves.
_ = Task.Run(() => JobQueue.DeleteStaleWorkFolders(JobQueue.DefaultWorkRoot, TimeSpan.FromDays(1)));
// Leftover update workspaces from cancelled or failed in-app updates (Linux only).
if (OperatingSystem.IsLinux())
_ = Task.Run(() => UpdateService.DeleteStaleWorkspaces());
// Once LibreOffice is installed: prepare its profiles while the user isn't converting yet.
AppHost.Get<EngineDownloadService>().WarmUpInBackground(TimeSpan.FromSeconds(10));

Expand Down Expand Up @@ -203,6 +206,7 @@ private void ListenForLaterLaunches(SingleInstance instance, ILogger log)
private static void WatchForUpdates(UserDataStore store)
{
var updates = AppHost.Get<UpdateService>();
updates.RequestApplicationShutdown = Quit;
// Required: the card every time Filee starts (UpdateFound fires once per run). Recommended: once per version.
// Optional: no card, the sidebar button only. Frequent releases no longer ask every day.
updates.UpdateFound += (_, version) =>
Expand All @@ -215,6 +219,9 @@ private static void WatchForUpdates(UserDataStore store)
AppHost.Get<WindowService>().ShowUpdateNotice(version);
};
updates.UpdateFailed += async (_, _) => await AppHost.Get<WindowService>().ShowUpdateFailedAsync();
// The last run's update failed after Filee closed (Linux): the installer rolled back and started this copy.
if (updates.CheckPendingFailure(store.Directory))
Dispatcher.UIThread.Post(async () => await AppHost.Get<WindowService>().ShowUpdateRolledBackAsync(updates.LastFailureLogPath));
updates.StartPeriodicChecks(() => store.Settings.CheckForUpdates, TimeSpan.FromSeconds(20));
DispatcherTimer.RunOnce(() => NotifyEngineUpdates(store), TimeSpan.FromSeconds(25));
}
Expand Down
2 changes: 2 additions & 0 deletions src/Filee.App/Assets/i18n/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,8 @@
"update.installing": "أداة التثبيت قيد التشغيل. سيبدأ Filee مجددًا عند اكتمالها.",
"update.failed": "تعذّر التحديث تلقائيًا. نزّل الإصدار الجديد من filee.sh.",
"update.failed_alert": "تعذّر تنزيل التحديث.\nاضغط على «موافق» لفتح filee.sh وتنزيل الإصدار الجديد منه.",
"update.rolled_back_alert": "تعذّر تثبيت التحديث، لذا أبقى Filee على الإصدار الذي كان لديك.\nاضغط على «موافق» لفتح filee.sh وتنزيل الإصدار الجديد منه.",
"update.rolled_back_log": "التفاصيل في السجل: {0}",
"update.cancelled": "أُلغي التحديث. اضغط على «تحديث» مجددًا لتثبيته.",
"update.level.Required": "تحديث ضروري",
"update.level.Recommended": "تحديث موصى به",
Expand Down
2 changes: 2 additions & 0 deletions src/Filee.App/Assets/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,8 @@
"update.installing": "The installer is running. Filee starts again when it is done.",
"update.failed": "Couldn't update automatically. Download the new version from filee.sh.",
"update.failed_alert": "Couldn't download the update.\nPress OK to open filee.sh and download the new version there.",
"update.rolled_back_alert": "Couldn't install the update, so Filee kept the version you had.\nPress OK to open filee.sh and download the new version there.",
"update.rolled_back_log": "Details are in the log: {0}",
"update.cancelled": "Update cancelled. Press Update again to install it.",
"update.level.Required": "Required update",
"update.level.Recommended": "Recommended update",
Expand Down
Loading
Loading