The list names each file and folder; upgrades and uninstall move only listed files and remove a folder only when empty. Every file, nested ones included, must be writable first. A new file whose place holds a file this app did not install refuses the install. A copy failing midway removes what it wrote and puts the old release back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
156 lines
6.5 KiB
Bash
156 lines
6.5 KiB
Bash
#!/usr/bin/env bash
|
|
# Installer upgrades remove only owned files; busy files change nothing.
|
|
set -euo pipefail
|
|
|
|
root=$(cd "$(dirname "$0")/../.." && pwd)
|
|
makensis=${MAKENSIS:-"/c/Program Files (x86)/NSIS/makensis.exe"}
|
|
case "$(uname -s)" in MINGW* | MSYS* | CYGWIN*) ;; *) echo "SKIP: not Windows"; exit 0 ;; esac
|
|
[ -x "$makensis" ] || { echo "SKIP: no makensis"; exit 0; }
|
|
export MSYS2_ARG_CONV_EXCL='*'
|
|
|
|
work=$root/target/installer-test
|
|
key='HKCU\Software\Ely Office\ElyProbe'
|
|
uninstall_key='HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\Ely Office ElyProbe'
|
|
clean() {
|
|
reg delete "$key" /f >/dev/null 2>&1 || true
|
|
reg delete "$uninstall_key" /f >/dev/null 2>&1 || true
|
|
rm -f "$APPDATA/Microsoft/Windows/Start Menu/Programs/ElyProbe.lnk"
|
|
}
|
|
trap 'kill $(jobs -p) 2>/dev/null || true; clean' EXIT
|
|
clean
|
|
rm -rf "$work"
|
|
mkdir -p "$work"
|
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
|
|
|
# Two releases of a fake app; v2 adds extra.txt and zz.
|
|
build() {
|
|
local version=$1 stage=$work/stage-$1
|
|
mkdir -p "$stage/licenses" "$stage/data" "$stage/bin"
|
|
cp /c/Windows/System32/PING.EXE "$stage/ely-probe.exe"
|
|
cp /c/Windows/System32/PING.EXE "$stage/bin/helper.exe"
|
|
echo "$version" >"$stage/ely-probe-worker.exe"
|
|
echo "$version" >"$stage/licenses/LICENSE"
|
|
echo "$version" >"$stage/data/info.txt"
|
|
[ "$version" = 1 ] || { echo "$version" >"$stage/extra.txt"; mkdir -p "$stage/zz"; echo 2 >"$stage/zz/deep.txt"; }
|
|
printf '!macro ElyTypesInstall\n!macroend\n!macro ElyTypesUninstall\n!macroend\n' >"$work/types.nsh"
|
|
"$root/packaging/windows/files.sh" "$stage" ely-probe >"$work/files-$version.txt"
|
|
"$makensis" -V1 -INPUTCHARSET UTF8 -DAPP_NAME=ElyProbe -DAPP_SLUG=ely-probe -DVERSION="$version" \
|
|
-DSTAGE="$(cygpath -w "$stage")" -DICON="$(cygpath -w "$root/assets/icons/ely-mark.ico")" \
|
|
-DTYPES="$(cygpath -w "$work/types.nsh")" -DLIST="$(cygpath -w "$work/files-$version.txt")" \
|
|
-DOUTFILE="$(cygpath -w "$work/setup-$version.exe")" "$(cygpath -w "$root/packaging/windows/installer.nsi")"
|
|
}
|
|
build 1
|
|
build 2
|
|
|
|
install() { "$work/setup-$1.exe" /S "/D=$(cygpath -w "$2")" && echo 0 || echo $?; }
|
|
snapshot() { (cd "$1" && find . -type f -exec md5sum {} + | sort); }
|
|
put() { mkdir -p "$(dirname "$1")" && echo user >"$1"; }
|
|
must() { [ -e "$1" ] || fail "$2: $1 is gone"; }
|
|
mustnt() { [ ! -e "$1" ] || fail "$2: $1 is still there"; }
|
|
# Holds a file open without delete or write sharing.
|
|
hold() {
|
|
rm -f "$work/held"
|
|
powershell -NoProfile -Command "\$f=[IO.File]::Open('$(cygpath -w "$1")','Open','Read','Read'); \
|
|
New-Item '$(cygpath -w "$work/held")' | Out-Null; Start-Sleep 60" &
|
|
until [ -e "$work/held" ]; do sleep 0.2; done
|
|
}
|
|
|
|
# A chosen folder's own files survive install and reinstall.
|
|
dir=$work/stranger
|
|
own="keep.exe keep.dll keep.json git/keep.txt notes/note.md licenses/my-notes.md"
|
|
for f in $own; do put "$dir/$f"; done
|
|
[ "$(install 1 "$dir")" = 0 ] || fail "stranger install"
|
|
[ "$(install 2 "$dir")" = 0 ] || fail "stranger reinstall"
|
|
for f in $own; do must "$dir/$f" stranger; done
|
|
must "$dir/extra.txt" "stranger reinstall"
|
|
grep -q '^2$' "$dir/licenses/LICENSE" || fail "stranger reinstall kept the old licence"
|
|
|
|
# Older releases are cleaned only where our key points.
|
|
legacy() {
|
|
for f in ely-probe.exe libcef.dll chrome_100.pak old.json git/cmd/git.exe locales/en.pak \
|
|
notes/note.md keep.md; do put "$1/$f"; done
|
|
}
|
|
# Another install's file in the way: refused, nothing changes.
|
|
untouched() {
|
|
local name=$1 dir=$2 before
|
|
before=$(snapshot "$dir")
|
|
[ "$(install 2 "$dir")" = 2 ] || fail "$name: installer did not refuse"
|
|
[ "$(snapshot "$dir")" = "$before" ] || fail "$name: the folder changed"
|
|
mustnt "$dir/.ely-old" "$name"
|
|
}
|
|
dir=$work/unkeyed
|
|
legacy "$dir"
|
|
untouched "unkeyed folder" "$dir"
|
|
dir=$work/taken
|
|
[ "$(install 1 "$dir")" = 0 ] || fail "taken setup"
|
|
put "$dir/extra.txt"
|
|
untouched "user file in the way" "$dir"
|
|
dir=$work/legacy
|
|
legacy "$dir"
|
|
reg add "$key" /v InstallDir /d "$(cygpath -w "$dir")" /f >/dev/null
|
|
[ "$(install 2 "$dir")" = 0 ] || fail "legacy upgrade"
|
|
for f in libcef.dll chrome_100.pak old.json git locales; do mustnt "$dir/$f" "legacy upgrade"; done
|
|
for f in notes/note.md keep.md extra.txt; do must "$dir/$f" "legacy upgrade"; done
|
|
|
|
# Busy files: the installer refuses; the old install stays whole.
|
|
busy() {
|
|
local name=$1 dir=$2 file=$3 before
|
|
before=$(snapshot "$dir")
|
|
hold "$file"
|
|
[ "$(install 2 "$dir")" = 2 ] || fail "$name: installer did not refuse"
|
|
kill %% 2>/dev/null; wait 2>/dev/null || true
|
|
[ "$(snapshot "$dir")" = "$before" ] || fail "$name: the old install changed"
|
|
mustnt "$dir/.ely-old" "$name"
|
|
}
|
|
dir=$work/busy
|
|
[ "$(install 1 "$dir")" = 0 ] || fail "busy setup"
|
|
busy "held program" "$dir" "$dir/ely-probe-worker.exe"
|
|
busy "held file in a folder" "$dir" "$dir/licenses/LICENSE"
|
|
dir=$work/busy-legacy
|
|
legacy "$dir"
|
|
reg add "$key" /v InstallDir /d "$(cygpath -w "$dir")" /f >/dev/null
|
|
busy "held legacy git" "$dir" "$dir/git/cmd/git.exe"
|
|
|
|
running() {
|
|
local name=$1 dir=$2 program=$3 before
|
|
before=$(snapshot "$dir")
|
|
"$program" -n 30 127.0.0.1 >/dev/null &
|
|
sleep 1
|
|
[ "$(install 2 "$dir")" = 2 ] || fail "$name: installer did not refuse"
|
|
kill %% 2>/dev/null; wait 2>/dev/null || true
|
|
[ "$(snapshot "$dir")" = "$before" ] || fail "$name: the old install changed"
|
|
mustnt "$dir/.ely-old" "$name"
|
|
}
|
|
dir=$work/running
|
|
[ "$(install 1 "$dir")" = 0 ] || fail "running setup"
|
|
running "running program" "$dir" "$dir/ely-probe.exe"
|
|
running "running program in a folder" "$dir" "$dir/bin/helper.exe"
|
|
dir=$work/running-legacy
|
|
legacy "$dir"
|
|
cp /c/Windows/System32/PING.EXE "$dir/git/cmd/git.exe"
|
|
reg add "$key" /v InstallDir /d "$(cygpath -w "$dir")" /f >/dev/null
|
|
running "running legacy git" "$dir" "$dir/git/cmd/git.exe"
|
|
|
|
# A copy failing after earlier writes puts the old release back.
|
|
dir=$work/rollback
|
|
[ "$(install 1 "$dir")" = 0 ] || fail "rollback setup"
|
|
put "$dir/zz/user.txt"
|
|
icacls "$(cygpath -w "$dir/zz")" /deny '*S-1-1-0:(WD)' >/dev/null
|
|
before=$(snapshot "$dir")
|
|
result=$(install 2 "$dir")
|
|
icacls "$(cygpath -w "$dir/zz")" /remove:d '*S-1-1-0' >/dev/null
|
|
[ "$result" != 0 ] || fail "rollback: blocked copy succeeded"
|
|
[ "$(snapshot "$dir")" = "$before" ] || fail "rollback: the old install changed"
|
|
mustnt "$dir/.ely-old" rollback
|
|
|
|
# Uninstalling removes only what was installed.
|
|
dir=$work/stranger
|
|
cp "$dir/uninstall.exe" "$work/uninstall.exe"
|
|
"$work/uninstall.exe" /S "_?=$(cygpath -w "$dir")" || fail "uninstall"
|
|
for f in $own; do must "$dir/$f" uninstall; done
|
|
for f in ely-probe.exe ely-probe-worker.exe extra.txt licenses/LICENSE data bin zz uninstall.exe .ely-installed; do
|
|
mustnt "$dir/$f" uninstall
|
|
done
|
|
rm -rf "$work"
|
|
echo "PASS: windows installer"
|