diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3a2e302c..28b466f7 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -141,5 +141,11 @@ not as a reason for mechanical splitting.
- Drafts under `docs/temp/` are planning material unless explicitly promoted.
- Version changes must go through `tools/bump-version.sh`; keep both package manifests, root
`Cargo.lock`, packaging metadata, and tag/release policy aligned.
+- Close a user-visible "I don't have that setting / this build" report only after the change is in
+ a tagged GitHub release. `main` is not what `arch-install.sh`, AUR `wayscriber-bin`, or other
+ packaged installs ship. `--version` reports the crate version, not the git hash, so bump with
+ `tools/bump-version.sh` in the same change as a user-visible overlay, settings, or config toggle
+ (or immediately before tagging that release). Otherwise two binaries can print the same
+ `wayscriber 0.9.x` and look identical.
See [tools/README.md](tools/README.md) for build, install, packaging, version, and release helpers.
diff --git a/README.md b/README.md
index 7fd82cfe..6a467195 100644
--- a/README.md
+++ b/README.md
@@ -281,10 +281,16 @@ sh arch-install.sh
```
The dry run verifies the latest stable release, its checksum and install manifest, the
-required Arch packages, and package ownership without installing files. The full run
-installs allowlisted files under `/usr/local`, refuses to shadow a pacman-owned Wayscriber
-installation, and does not start or restart the user service. To update this installation,
-download a fresh script, rerun it, then restart `wayscriber.service` when ready.
+required Arch packages, and package ownership without installing files. Both the dry run
+and the full run refuse another Wayscriber copy that is not this install: an unmanaged
+file under `/usr`, `~/.local/bin/wayscriber`, or a user unit / drop-in whose `ExecStart`
+is not `/usr/local/bin/wayscriber`. A symlink that already resolves to the dest is not a
+conflict. Pass `--replace-other` on those commands to remove the other copy first. If
+`wayscriber.service` is already active, the installer restarts it so the overlay daemon
+follows `/usr/local/bin/wayscriber`; pass `--no-restart` to leave the running unit alone.
+`--version` is the crate version, not a git hash, so inspect the installed path and the
+running service `ExecStart` after install. To update this installation, download a fresh
+script and rerun it.
Remove the direct installation before moving to the AUR. Otherwise, `/usr/local/bin` and
`/usr/local/lib/systemd/user` continue to take priority over the package files under
@@ -299,10 +305,12 @@ yay -S wayscriber-bin
systemctl --user enable --now wayscriber.service
```
-The uninstaller removes only unmanaged files in the direct install manifest, including
-the same files from the legacy `/usr` location. It leaves pacman-owned files and your user
-configuration and data unchanged, and refuses actual removal while the service is active
-or enabled.
+The uninstaller removes only unmanaged files in the direct install manifest under
+`/usr/local`. It leaves pacman-owned files and your user configuration and data unchanged,
+and refuses actual removal while the service is active or enabled. A leftover unmanaged
+git build under `/usr` is left in place; pass `--remove-unmanaged-usr` only when that
+`/usr` copy should go too. Default uninstall does not remove `~/.local/bin/wayscriber` or
+a user unit / drop-in; the script warns if those still point at `/usr/local`.
### NixOS and Nix
diff --git a/docs/SETUP.md b/docs/SETUP.md
index 2a2d01f3..26cf9daf 100644
--- a/docs/SETUP.md
+++ b/docs/SETUP.md
@@ -37,8 +37,13 @@ Run the install script:
This will:
1. Build the release binary
-2. Copy it to `~/.local/bin/wayscriber`
-3. Tell you how to add Hyprland keybind
+2. Copy it to `/usr/bin/wayscriber` (or `$WAYSCRIBER_INSTALL_DIR`)
+3. Refuse a second copy under `/usr/local/bin` or `~/.local/bin` unless you pass `--replace-other` or confirm
+4. Optionally set up the systemd user service or a Hyprland keybind
+
+If `/usr/local/bin/wayscriber` already exists from the direct Arch installer, keep only one
+prefix. Overlay spawn follows the running daemon file, not whichever path you passed to
+`--version` or `--about`.
### Manual Install
@@ -250,9 +255,15 @@ Press Escape (should hide overlay)
- If `wayscriber` is not found from the compositor, use the absolute path from `command -v wayscriber`
**Binary not found?**
-- Check PATH: `echo $PATH | grep .local/bin`
-- Add to PATH if missing (see Manual Install)
-- Restart terminal after PATH change
+- Check `type -a wayscriber` and the path you installed to (`/usr/bin`, `/usr/local/bin`, or `~/.local/bin`)
+- If you used `./tools/install.sh`, the default dest is `/usr/bin/wayscriber` (or `$WAYSCRIBER_INSTALL_DIR`)
+- If you copied the binary by hand, add `~/.local/bin` to PATH (see Manual Install) and restart the terminal
+
+**Settings or `--about` do not match the binary you just built?**
+- Two copies can exist: the direct installer uses `/usr/local`, source `tools/install.sh` uses `/usr/bin`, and a manual copy may live in `~/.local/bin`.
+- Check `type -a wayscriber`, `systemctl --user show wayscriber.service -p ExecStart,MainPID,FragmentPath`, and `readlink -f /proc/$(systemctl --user show -p MainPID --value wayscriber.service)/exe`.
+- `--version` is the crate version and can be identical on both copies. `--about` opens a window; use it on the path you intend to run, not as a substitute for the running daemon path.
+- Keep one prefix. `./tools/install.sh --replace-other` removes the other known copies when installing to `/usr/bin`.
**Want different key?**
- Edit `bindings.lua` or `hyprland.conf`, depending on your Hyprland config format
@@ -263,6 +274,16 @@ Press Escape (should hide overlay)
## Uninstall
+If you used `./tools/install.sh` (default `/usr/bin`):
+
+```bash
+sudo rm -f /usr/bin/wayscriber
+systemctl --user disable --now wayscriber.service
+# Remove the keybind from bindings.lua or hyprland.conf
+```
+
+If you copied the binary by hand to `~/.local/bin`:
+
```bash
rm ~/.local/bin/wayscriber
# Remove the keybind from bindings.lua or hyprland.conf
diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md
index 3ac017ec..1872c74a 100644
--- a/docs/codebase-overview.md
+++ b/docs/codebase-overview.md
@@ -41,7 +41,10 @@ backend entry in `src/backend/mod.rs`.
action applier.
5. Overlay candidates and runtime helpers are created only by the pre-lock process broker. The
daemon owns generation/pidfd decisions while the broker owns wait/reap; overlay readiness is
- accepted only after the child wins its lock and publishes matching process identity.
+ accepted only after the child wins its lock and publishes matching process identity. Startup
+ also warns when another `wayscriber` file exists on `PATH` or under `/usr`, `/usr/local`,
+ or `~/.local/bin`, because overlay spawn follows this process file rather than whichever
+ path the user inspected.
6. Queue renames, producer eventfds, signals, and child pidfds drive the loop without a lifecycle
polling tick. Shutdown invalidates readiness, terminates owned work, and joins listeners.
diff --git a/src/daemon/AGENTS.md b/src/daemon/AGENTS.md
index 1f4e898a..fd19be9e 100644
--- a/src/daemon/AGENTS.md
+++ b/src/daemon/AGENTS.md
@@ -5,6 +5,7 @@
## Architecture
- `core.rs`, `control.rs`, and `types.rs` own daemon state and toggle/control behavior.
+- `binary_conflict.rs` warns when another Wayscriber binary exists besides the running daemon.
- `overlay/` owns overlay process spawn/control.
- `tray/` owns tray integration and shortcut hint I/O.
- `setup.rs` and `global_shortcuts.rs` support daemon setup workflows.
diff --git a/src/daemon/binary_conflict.rs b/src/daemon/binary_conflict.rs
new file mode 100644
index 00000000..4e06dd5a
--- /dev/null
+++ b/src/daemon/binary_conflict.rs
@@ -0,0 +1,186 @@
+use std::collections::HashSet;
+use std::env;
+use std::path::{Path, PathBuf};
+
+use log::warn;
+
+use crate::env_vars::PATH_ENV;
+use crate::paths::home_dir;
+
+/// Prefixes this project actually installs to, including ones the packaged
+/// user unit PATH (`/usr/local/bin:/usr/bin:/bin`) does not search.
+///
+/// `PATH` is scanned separately. Do not add cargo/nix/opt folders here unless
+/// a Wayscriber installer writes them; those still show up when they are on
+/// `PATH`.
+fn well_known_wayscriber_binaries() -> Vec {
+ let mut paths = vec![
+ PathBuf::from("/usr/bin/wayscriber"),
+ PathBuf::from("/usr/local/bin/wayscriber"),
+ ];
+ if let Some(home) = home_dir() {
+ paths.push(home.join(".local").join("bin").join("wayscriber"));
+ }
+ paths
+}
+
+fn canonicalize_file(path: &Path) -> Option {
+ if !path.is_file() {
+ return None;
+ }
+ path.canonicalize().ok()
+}
+
+/// Other `wayscriber` files on `PATH` or in well-known prefixes besides this process.
+///
+/// Overlay spawn follows `current_exe` first. A second copy is how `--version` or
+/// `--about` on a different path can disagree with the running daemon.
+pub(crate) fn other_installed_wayscriber_binaries(
+ current_exe: &Path,
+ path_env: Option<&str>,
+ extra_candidates: &[&Path],
+) -> Vec {
+ let Some(current) = canonicalize_file(current_exe) else {
+ return Vec::new();
+ };
+
+ let mut seen = HashSet::new();
+ seen.insert(current);
+ let mut others = Vec::new();
+
+ let mut consider = |candidate: &Path| {
+ let Some(canonical) = canonicalize_file(candidate) else {
+ return;
+ };
+ if seen.insert(canonical.clone()) {
+ others.push(canonical);
+ }
+ };
+
+ if let Some(path_env) = path_env {
+ for dir in env::split_paths(path_env) {
+ consider(&dir.join("wayscriber"));
+ }
+ }
+ for candidate in extra_candidates {
+ consider(candidate);
+ }
+
+ others
+}
+
+pub(super) fn warn_if_other_wayscriber_binaries() {
+ let Ok(exe) = env::current_exe() else {
+ return;
+ };
+ let path_env = env::var(PATH_ENV).ok();
+ let extras = well_known_wayscriber_binaries();
+ let extra_refs: Vec<&Path> = extras.iter().map(PathBuf::as_path).collect();
+ let others = other_installed_wayscriber_binaries(&exe, path_env.as_deref(), &extra_refs);
+ if others.is_empty() {
+ return;
+ }
+ let other_list = others
+ .iter()
+ .map(|path| path.display().to_string())
+ .collect::>()
+ .join(", ");
+ warn!(
+ "Another wayscriber binary exists besides this daemon ({}). Overlay spawn follows this process file; inspecting a different path with --version or --about can disagree. Other copies: {}",
+ exe.display(),
+ other_list
+ );
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::fs;
+ use std::os::unix::fs::symlink;
+
+ fn write_file(path: &Path) {
+ fs::write(path, b"wayscriber-test-binary").unwrap();
+ }
+
+ #[test]
+ fn no_conflict_when_path_and_extras_are_the_same_file() {
+ let tmp = crate::test_temp::tempdir().unwrap();
+ let binary = tmp.path().join("wayscriber");
+ write_file(&binary);
+ let path_env = tmp.path().display().to_string();
+
+ assert!(
+ other_installed_wayscriber_binaries(&binary, Some(&path_env), &[&binary]).is_empty()
+ );
+ }
+
+ #[test]
+ fn reports_a_second_file_on_path() {
+ let tmp = crate::test_temp::tempdir().unwrap();
+ let current_dir = tmp.path().join("current");
+ let other_dir = tmp.path().join("other");
+ fs::create_dir(¤t_dir).unwrap();
+ fs::create_dir(&other_dir).unwrap();
+ let current = current_dir.join("wayscriber");
+ let other = other_dir.join("wayscriber");
+ write_file(¤t);
+ write_file(&other);
+ let path_env = format!("{}:{}", other_dir.display(), current_dir.display());
+
+ let others = other_installed_wayscriber_binaries(¤t, Some(&path_env), &[]);
+ assert_eq!(others, vec![other.canonicalize().unwrap()]);
+ }
+
+ #[test]
+ fn reports_a_well_known_copy_even_when_path_matches_current() {
+ let tmp = crate::test_temp::tempdir().unwrap();
+ let current_dir = tmp.path().join("local");
+ let usr_dir = tmp.path().join("usr");
+ fs::create_dir(¤t_dir).unwrap();
+ fs::create_dir(&usr_dir).unwrap();
+ let current = current_dir.join("wayscriber");
+ let usr = usr_dir.join("wayscriber");
+ write_file(¤t);
+ write_file(&usr);
+ let path_env = current_dir.display().to_string();
+
+ let others = other_installed_wayscriber_binaries(¤t, Some(&path_env), &[&usr]);
+ assert_eq!(others, vec![usr.canonicalize().unwrap()]);
+ }
+
+ #[test]
+ fn symlink_to_the_same_file_is_not_a_conflict() {
+ let tmp = crate::test_temp::tempdir().unwrap();
+ let current = tmp.path().join("wayscriber");
+ let alias = tmp.path().join("alias-wayscriber");
+ write_file(¤t);
+ symlink(¤t, &alias).unwrap();
+
+ assert!(other_installed_wayscriber_binaries(¤t, None, &[&alias]).is_empty());
+ }
+
+ #[test]
+ fn missing_current_exe_yields_no_others() {
+ let tmp = crate::test_temp::tempdir().unwrap();
+ let missing = tmp.path().join("missing");
+ let other = tmp.path().join("wayscriber");
+ write_file(&other);
+
+ assert!(other_installed_wayscriber_binaries(&missing, None, &[&other]).is_empty());
+ }
+
+ #[test]
+ fn well_known_includes_resolved_user_local_bin() {
+ let tmp = crate::test_temp::tempdir().unwrap();
+ crate::test_env::with_env_var(
+ crate::env_vars::HOME_ENV,
+ Some(tmp.path().as_os_str()),
+ || {
+ let paths = well_known_wayscriber_binaries();
+ assert!(paths.contains(&PathBuf::from("/usr/bin/wayscriber")));
+ assert!(paths.contains(&PathBuf::from("/usr/local/bin/wayscriber")));
+ assert!(paths.contains(&tmp.path().join(".local").join("bin").join("wayscriber")));
+ },
+ );
+ }
+}
diff --git a/src/daemon/core.rs b/src/daemon/core.rs
index d857feef..26d114e5 100644
--- a/src/daemon/core.rs
+++ b/src/daemon/core.rs
@@ -245,6 +245,7 @@ impl Daemon {
/// Run daemon with signal handling
pub fn run(&mut self) -> Result<()> {
info!("Starting wayscriber daemon");
+ super::binary_conflict::warn_if_other_wayscriber_binaries();
if self.freeze_on_show {
info!("Daemon activations will request frozen mode on show");
}
diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs
index 2dae3d91..cf79300f 100644
--- a/src/daemon/mod.rs
+++ b/src/daemon/mod.rs
@@ -1,5 +1,6 @@
//! Daemon mode implementation: background service with toggle activation
+mod binary_conflict;
mod control;
mod core;
mod global_shortcuts;
diff --git a/tools/AGENTS.md b/tools/AGENTS.md
index ccec43b7..4c9d33f9 100644
--- a/tools/AGENTS.md
+++ b/tools/AGENTS.md
@@ -17,6 +17,8 @@
## Coupled Changes
- Version and packaging scripts must stay aligned with `tools/README.md`, `packaging/`, `.github/`, `Cargo.toml`, and release docs.
- Install/reload scripts may affect setup docs and daemon service behavior.
+- `install.sh` and the website `arch-install.sh` must refuse a second unmanaged prefix
+ (`/usr` vs `/usr/local`) unless the operator opts into replacing the other copy.
## Validation
- Run changed scripts directly when safe.
diff --git a/tools/README.md b/tools/README.md
index bdf51181..b734cf97 100644
--- a/tools/README.md
+++ b/tools/README.md
@@ -41,9 +41,11 @@ Helper scripts for development, installation, packaging, and release workflows.
- **install.sh** - Full installation script
- Builds and installs binary to `/usr/bin` (or `$WAYSCRIBER_INSTALL_DIR`)
+ - Refuses a second copy under `/usr/bin`, `/usr/local/bin`, or `~/.local/bin` unless
+ `--replace-other` is passed or you confirm on a TTY
- Sets up config directory with example config
- Optionally configures systemd service or Hyprland autostart
- - Usage: `./tools/install.sh`
+ - Usage: `./tools/install.sh [--replace-other]`
- **install-configurator.sh** - Install configurator only
- Builds and installs wayscriber-configurator, its desktop entry, and icons
@@ -60,6 +62,8 @@ Helper scripts for development, installation, packaging, and release workflows.
- Updates Cargo.toml, configurator/Cargo.toml, the workspace Cargo.lock, PKGBUILD, and .SRCINFO
- flake.nix package version follows Cargo.toml automatically
- Auto-increments patch version if no version specified
+ - Use this in the same change as a user-visible overlay/settings/config toggle, or immediately
+ before tagging that release, so `--version` is not identical to the last shipped crate
- Supports MAJOR.MINOR.PATCH.HOTFIX for packaging-only hotfix releases
- Usage: `./tools/bump-version.sh [--dry-run] [new_version]`
@@ -100,6 +104,8 @@ Packaging-only hotfix policy:
- Strictly parses the installer's static allowlist as data; it never executes the installer, and unsupported manifest syntax fails closed
- Requires the archive file set, modes, and service command to match what the installer accepts
- Runs against the deployed installer during release packaging
+ - Live `https://wayscriber.com/arch-install.sh` is what CI fetches. Dual-prefix flags in a
+ local website checkout are not checked until that file is published.
- Usage: `./tools/check-arch-installer-manifest.sh --installer FILE --archive FILE`
When the tarball file manifest changes, build and check the new tarball locally, deploy
diff --git a/tools/install.sh b/tools/install.sh
index d07ed812..1a2dad38 100755
--- a/tools/install.sh
+++ b/tools/install.sh
@@ -9,22 +9,54 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
INSTALL_DIR="${WAYSCRIBER_INSTALL_DIR:-/usr/bin}"
+while [ "$INSTALL_DIR" != "/" ] && [ "${INSTALL_DIR%/}" != "$INSTALL_DIR" ]; do
+ INSTALL_DIR="${INSTALL_DIR%/}"
+done
+case "$INSTALL_DIR" in
+ /usr) INSTALL_DIR="/usr/bin" ;;
+ /usr/local) INSTALL_DIR="/usr/local/bin" ;;
+esac
BINARY_NAME="wayscriber"
INSTALLED_BINARY="$INSTALL_DIR/$BINARY_NAME"
BIND_COMMAND="$INSTALL_DIR/$BINARY_NAME --daemon-toggle"
CONFIG_DIR="$HOME/.config/wayscriber"
HYPR_CONFIG="$HOME/.config/hypr/hyprland.conf"
-
-echo "================================"
-echo " Wayscriber Installation"
-echo "================================"
-echo ""
+REPLACE_OTHER=0
die() {
echo "❌ $*" >&2
exit 1
}
+usage() {
+ echo "Usage: $0 [--replace-other]"
+ echo "Build a source binary and install it to $INSTALL_DIR."
+ echo "Refuses a second copy under /usr/bin, /usr/local/bin, or ~/.local/bin"
+ echo "unless --replace-other is passed or you confirm on a TTY."
+}
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --replace-other)
+ [ "$REPLACE_OTHER" -eq 0 ] || die "--replace-other was specified more than once"
+ REPLACE_OTHER=1
+ ;;
+ --help|-h)
+ usage
+ exit 0
+ ;;
+ *)
+ die "unexpected argument: $1"
+ ;;
+ esac
+ shift
+done
+
+echo "================================"
+echo " Wayscriber Installation"
+echo "================================"
+echo ""
+
ensure_replacement() {
local file="$1"
local search="$2"
@@ -42,6 +74,191 @@ ensure_replacement() {
fi
}
+canonical_path() {
+ readlink -f "$1" 2>/dev/null || printf '%s' "$1"
+}
+
+same_file() {
+ local left right
+ { [ -e "$1" ] || [ -L "$1" ]; } || return 1
+ left="$(canonical_path "$1")"
+ right="$(canonical_path "$2")"
+ [ -n "$left" ] && { [ "$left" = "$right" ] || [ "$left" = "$2" ]; }
+}
+
+systemd_user_dir() {
+ if [ -n "${XDG_CONFIG_HOME:-}" ]; then
+ printf '%s\n' "${XDG_CONFIG_HOME}/systemd/user"
+ elif [ -n "${HOME:-}" ]; then
+ printf '%s\n' "${HOME}/.config/systemd/user"
+ fi
+}
+
+known_wayscriber_binaries() {
+ printf '%s\n' /usr/bin/wayscriber /usr/local/bin/wayscriber
+ if [ -n "${HOME:-}" ]; then
+ printf '%s\n' "$HOME/.local/bin/wayscriber"
+ fi
+}
+
+known_prefix_units() {
+ printf '%s\n' \
+ /usr/lib/systemd/user/wayscriber.service \
+ /usr/local/lib/systemd/user/wayscriber.service
+}
+
+user_unit_files() {
+ local dir conf
+ dir="$(systemd_user_dir)"
+ [ -n "$dir" ] || return 0
+ [ -f "$dir/wayscriber.service" ] && printf '%s\n' "$dir/wayscriber.service"
+ if [ -d "$dir/wayscriber.service.d" ]; then
+ for conf in "$dir/wayscriber.service.d/"*.conf; do
+ [ -f "$conf" ] || continue
+ printf '%s\n' "$conf"
+ done
+ fi
+}
+
+file_has_conflicting_exec_start() {
+ local file="$1" dest="$2" dest_canon other
+ [ -f "$file" ] || return 1
+ grep -Eq '^[[:space:]]*ExecStart=' "$file" || return 1
+ dest_canon="$(canonical_path "$dest")"
+ if grep -E '^[[:space:]]*ExecStart=' "$file" | grep -Fq "$dest"; then
+ return 1
+ fi
+ while IFS= read -r other; do
+ grep -E '^[[:space:]]*ExecStart=' "$file" | grep -Fq "$other" || continue
+ if same_file "$other" "$dest"; then
+ return 1
+ fi
+ return 0
+ done < <(known_wayscriber_binaries)
+ return 0
+}
+
+unit_paths_for_binary() {
+ case "$1" in
+ /usr/local/bin/wayscriber)
+ printf '%s\n' /usr/local/lib/systemd/user/wayscriber.service
+ ;;
+ /usr/bin/wayscriber)
+ printf '%s\n' /usr/lib/systemd/user/wayscriber.service
+ ;;
+ esac
+}
+
+path_is_package_owned() {
+ command -v pacman >/dev/null 2>&1 || return 1
+ pacman -Qoq "$1" >/dev/null 2>&1
+}
+
+user_unit_conflicts() {
+ local file
+ while IFS= read -r file; do
+ file_has_conflicting_exec_start "$file" "$INSTALLED_BINARY" && return 0
+ done < <(user_unit_files)
+ return 1
+}
+
+collect_conflicts() {
+ OTHER_BINARIES=()
+ OTHER_UNITS=()
+ local dest other unit
+ dest="$(canonical_path "$INSTALLED_BINARY")"
+ while IFS= read -r other; do
+ [ -e "$other" ] || [ -L "$other" ] || continue
+ if [ "$other" = "$INSTALLED_BINARY" ] || [ "$(canonical_path "$other")" = "$dest" ]; then
+ continue
+ fi
+ OTHER_BINARIES+=("$other")
+ done < <(known_wayscriber_binaries)
+ while IFS= read -r unit; do
+ [ -e "$unit" ] || [ -L "$unit" ] || continue
+ file_has_conflicting_exec_start "$unit" "$INSTALLED_BINARY" || continue
+ OTHER_UNITS+=("$unit")
+ done < <(known_prefix_units)
+}
+
+removal_paths_for_binary() {
+ local binary="$1" unit
+ printf '%s\n' "$binary"
+ while IFS= read -r unit; do
+ [ -e "$unit" ] || [ -L "$unit" ] || continue
+ printf '%s\n' "$unit"
+ done < <(unit_paths_for_binary "$binary")
+}
+
+remove_path_if_unmanaged() {
+ local path="$1"
+ if [ ! -e "$path" ] && [ ! -L "$path" ]; then
+ return 0
+ fi
+ if path_is_package_owned "$path"; then
+ if [ "$(basename "$path")" = "wayscriber" ]; then
+ die "${path} is owned by Arch package $(pacman -Qoq "$path"); remove or update that package instead"
+ fi
+ echo "Leaving package-owned $path ($(pacman -Qoq "$path"))"
+ return 0
+ fi
+ ${SUDO:-} rm -f -- "$path"
+ echo "Removed $path"
+}
+
+remove_conflicting_other_binary() {
+ local other="$1" path
+ while IFS= read -r path; do
+ remove_path_if_unmanaged "$path"
+ done < <(removal_paths_for_binary "$other")
+}
+
+remove_conflicting_user_units() {
+ local file
+ while IFS= read -r file; do
+ file_has_conflicting_exec_start "$file" "$INSTALLED_BINARY" || continue
+ rm -f -- "$file"
+ echo "Removed user unit override $file"
+ done < <(user_unit_files)
+}
+
+has_install_conflicts() {
+ [ "${#OTHER_BINARIES[@]}" -gt 0 ] || [ "${#OTHER_UNITS[@]}" -gt 0 ] || user_unit_conflicts
+}
+
+collect_conflicts
+if has_install_conflicts; then
+ echo "Another wayscriber install would stay beside $INSTALLED_BINARY:"
+ for other in "${OTHER_BINARIES[@]+"${OTHER_BINARIES[@]}"}"; do
+ echo " $other"
+ while IFS= read -r path; do
+ echo " and $path"
+ done < <(removal_paths_for_binary "$other" | tail -n +2)
+ done
+ for unit in "${OTHER_UNITS[@]+"${OTHER_UNITS[@]}"}"; do
+ echo " $unit (ExecStart is not $INSTALLED_BINARY)"
+ done
+ while IFS= read -r file; do
+ file_has_conflicting_exec_start "$file" "$INSTALLED_BINARY" || continue
+ echo " $file (ExecStart is not $INSTALLED_BINARY)"
+ done < <(user_unit_files)
+ echo "Leaving both copies makes the overlay daemon follow a different file than the one you inspect."
+ if [ "$REPLACE_OTHER" -eq 0 ] && [ -t 0 ]; then
+ read -r -p "Remove those files so only $INSTALLED_BINARY remains? [y/N] " CONFIRM
+ echo ""
+ case "$CONFIRM" in
+ y|Y|yes|YES)
+ REPLACE_OTHER=1
+ ;;
+ *)
+ die "Refusing to install beside the other copy. Re-run with --replace-other after removing it."
+ ;;
+ esac
+ elif [ "$REPLACE_OTHER" -eq 0 ]; then
+ die "Refusing to install beside the other copy. Re-run with --replace-other, or remove it first."
+ fi
+fi
+
# Ensure required binaries are built
echo "Building Wayscriber binary (release, default features)..."
(cd "$PROJECT_ROOT" && cargo build --release --bins)
@@ -61,6 +278,27 @@ if [ ! -d "$INSTALL_DIR" ] || [ ! -w "$INSTALL_DIR" ]; then
fi
fi
+if has_install_conflicts && [ "$REPLACE_OTHER" -eq 1 ] && [ "$(id -u)" -ne 0 ]; then
+ if [ -z "${SUDO:-}" ]; then
+ if command -v sudo >/dev/null 2>&1; then
+ SUDO="sudo"
+ echo "Using sudo to remove the other Wayscriber copy"
+ else
+ die "sudo is required to remove the other Wayscriber copy"
+ fi
+ fi
+fi
+
+if [ "$REPLACE_OTHER" -eq 1 ]; then
+ for other in "${OTHER_BINARIES[@]+"${OTHER_BINARIES[@]}"}"; do
+ remove_conflicting_other_binary "$other"
+ done
+ for unit in "${OTHER_UNITS[@]+"${OTHER_UNITS[@]}"}"; do
+ remove_path_if_unmanaged "$unit"
+ done
+ remove_conflicting_user_units
+fi
+
# Ensure install directory exists
${SUDO:-} install -d "$INSTALL_DIR"
@@ -98,10 +336,35 @@ echo "Installed: $INSTALLED_BINARY"
ls -l "$INSTALLED_BINARY"
echo "SHA256: $(sha256sum "$INSTALLED_BINARY" | cut -d' ' -f1)"
if command -v "$BINARY_NAME" >/dev/null 2>&1; then
- echo "PATH: $(command -v "$BINARY_NAME")"
+ PATH_BINARY="$(command -v "$BINARY_NAME")"
+ echo "PATH: $PATH_BINARY"
+ if [ "$(readlink -f "$PATH_BINARY" 2>/dev/null || printf '%s' "$PATH_BINARY")" != \
+ "$(readlink -f "$INSTALLED_BINARY" 2>/dev/null || printf '%s' "$INSTALLED_BINARY")" ]; then
+ echo "⚠️ PATH resolves $PATH_BINARY, which is not the file just installed."
+ echo " Overlay spawn follows the running daemon, not this PATH lookup."
+ fi
else
echo "PATH: not found in PATH"
fi
+while IFS= read -r other; do
+ [ -e "$other" ] || [ -L "$other" ] || continue
+ if [ "$other" = "$INSTALLED_BINARY" ]; then
+ continue
+ fi
+ if [ "$(canonical_path "$other")" = "$(canonical_path "$INSTALLED_BINARY")" ]; then
+ continue
+ fi
+ echo "⚠️ $other still exists beside $INSTALLED_BINARY"
+done < <(known_wayscriber_binaries)
+while IFS= read -r unit; do
+ [ -e "$unit" ] || [ -L "$unit" ] || continue
+ file_has_conflicting_exec_start "$unit" "$INSTALLED_BINARY" || continue
+ echo "⚠️ $unit still has ExecStart that is not $INSTALLED_BINARY"
+done < <(known_prefix_units)
+while IFS= read -r file; do
+ file_has_conflicting_exec_start "$file" "$INSTALLED_BINARY" || continue
+ echo "⚠️ $file still has ExecStart that is not $INSTALLED_BINARY"
+done < <(user_unit_files)
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Setup Instructions"
@@ -139,7 +402,7 @@ echo ""
case $REPLY in
1)
- SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
+ SYSTEMD_USER_DIR="$(systemd_user_dir)"
SYSTEMD_SYSTEM_DIR="/usr/lib/systemd/user"
USER_SERVICE_FILE="$SYSTEMD_USER_DIR/wayscriber.service"
SYSTEM_SERVICE_FILE="$SYSTEMD_SYSTEM_DIR/wayscriber.service"
diff --git a/tools/test-release-packaging.sh b/tools/test-release-packaging.sh
index 2758159a..7b005ab2 100755
--- a/tools/test-release-packaging.sh
+++ b/tools/test-release-packaging.sh
@@ -361,6 +361,9 @@ assert_contains "${WORK_DIR}/release-package-job.yml" "https://wayscriber.com/ar
assert_contains "${WORK_DIR}/release-package-job.yml" "./tools/check-arch-installer-manifest.sh"
assert_contains "${WORK_DIR}/release-package-job.yml" 'wayscriber-v${{ steps.meta.outputs.version }}-linux-x86_64.tar.gz'
assert_not_contains "${WORK_DIR}/release-package-job.yml" 'sh "$RUNNER_TEMP/arch-install.sh"'
+assert_contains "${REPO_ROOT}/README.md" "--replace-other"
+assert_contains "${REPO_ROOT}/README.md" "--no-restart"
+assert_contains "${REPO_ROOT}/README.md" "--remove-unmanaged-usr"
test -x "${ARCH_INSTALLER_CHECKER}"
assert_contains "${ARCH_INSTALLER_CHECKER}" "The installer is parsed as data and is not run."