| 1 | #!/usr/bin/env bash |
| 2 | # Verify the Windows portable release unit (versioned-v1) before packaging. |
| 3 | # This must run on the Windows staging directory: NTFS treats file names |
| 4 | # case-insensitively, so Reasonix.exe and reasonix.exe silently overwrite each |
| 5 | # other even though a source-level packaging test sees two different strings. |
| 6 | set -euo pipefail |
| 7 | |
| 8 | staging="${1:?usage: verify-windows-portable.sh STAGING_DIR [canonical|legacy-dual] [PAYLOAD_LAUNCHER]}" |
| 9 | layout="${2:-canonical}" |
| 10 | payload_launcher="${3:-}" |
| 11 | case "$layout" in |
| 12 | canonical|legacy-dual) ;; |
| 13 | *) echo "Unknown Windows portable layout: $layout" >&2; exit 1 ;; |
| 14 | esac |
| 15 | [ -d "$staging" ] || { echo "Windows portable staging directory is missing: $staging" >&2; exit 1; } |
| 16 | |
| 17 | # Root entry points for versioned-v1 (no Guard, no flat desktop/helper). |
| 18 | required_root=( |
| 19 | "Reasonix.exe" |
| 20 | "reasonix-cli.exe" |
| 21 | "current.json" |
| 22 | ) |
| 23 | if [ "$layout" = "legacy-dual" ]; then |
| 24 | required_root+=("reasonix-launcher.exe") |
| 25 | fi |
| 26 | |
| 27 | for expected in "${required_root[@]}"; do |
| 28 | [ -f "$staging/$expected" ] && [ ! -L "$staging/$expected" ] || { |
| 29 | echo "Windows portable root entry is missing: $expected" >&2 |
| 30 | exit 1 |
| 31 | } |
| 32 | done |
| 33 | |
| 34 | # Exactly one active version directory under versions/v*. |
| 35 | version_dirs=() |
| 36 | if [ -d "$staging/versions" ]; then |
| 37 | while IFS= read -r -d '' d; do |
| 38 | version_dirs+=("$d") |
| 39 | done < <(find "$staging/versions" -mindepth 1 -maxdepth 1 -type d -name 'v*' -print0 2>/dev/null || true) |
| 40 | fi |
| 41 | if [ "${#version_dirs[@]}" -ne 1 ]; then |
| 42 | echo "Windows portable must contain exactly one active version directory" >&2 |
| 43 | exit 1 |
| 44 | fi |
| 45 | |
| 46 | # current.json must point at a real version directory. |
| 47 | active_dir=$(node -e ' |
| 48 | const fs = require("node:fs"); |
| 49 | const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); |
| 50 | if (p.schemaVersion !== 1 || !/^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?$/.test(p.activeVersion) || |
| 51 | p.activeDir !== `versions/${p.activeVersion}` || |
| 52 | Object.keys(p).some(k => !["schemaVersion", "activeVersion", "activeDir"].includes(k))) { |
| 53 | throw new Error("Invalid portable current.json"); |
| 54 | } |
| 55 | console.log(p.activeDir); |
| 56 | ' "$staging/current.json") |
| 57 | [ -n "$active_dir" ] || { |
| 58 | echo "Windows portable current.json activeDir is empty or unreadable" >&2 |
| 59 | exit 1 |
| 60 | } |
| 61 | version_path="$staging/$active_dir" |
| 62 | [ -d "$version_path" ] || { |
| 63 | echo "Windows portable activeDir does not exist: $active_dir" >&2 |
| 64 | exit 1 |
| 65 | } |
| 66 | for name in reasonix-desktop.exe reasonix-cli.exe reasonix-update-helper.exe; do |
| 67 | [ -f "$version_path/$name" ] || { |
| 68 | echo "Windows portable version member is missing: $active_dir/$name" >&2 |
| 69 | exit 1 |
| 70 | } |
| 71 | done |
| 72 | |
| 73 | # The Electron bundle is the app/ tree member of the active version. |
| 74 | for name in "app/Reasonix.exe" "app/resources/app.asar" "app/resources/build.json" "app/resources/app/index.html" "app/resources/bin/reasonix-cli-launcher.exe"; do |
| 75 | [ -f "$version_path/$name" ] || { |
| 76 | echo "Windows portable app tree member is missing: $active_dir/$name" >&2 |
| 77 | exit 1 |
| 78 | } |
| 79 | done |
| 80 | |
| 81 | # Guard must not persist in a normal portable layout. |
| 82 | if [ -e "$staging/reasonix-guard.exe" ] || [ -e "$staging/reasonix-desktop.exe" ]; then |
| 83 | echo "Windows portable must not ship flat reasonix-guard.exe or reasonix-desktop.exe at InstallRoot" >&2 |
| 84 | exit 1 |
| 85 | fi |
| 86 | |
| 87 | # Historical packages must contain identical launcher entries. New artifacts |
| 88 | # must not regain the compatibility entry merely because it exists in staging. |
| 89 | if [ "$layout" = "legacy-dual" ]; then |
| 90 | cmp -s "$staging/Reasonix.exe" "$staging/reasonix-launcher.exe" || { |
| 91 | echo "Reasonix.exe differs from the legacy GUI entry" >&2; exit 1 |
| 92 | } |
| 93 | fi |
| 94 | if [ -n "$payload_launcher" ]; then |
| 95 | cmp -s "$staging/Reasonix.exe" "$payload_launcher" || { |
| 96 | echo "Reasonix.exe does not match the payload launcher" >&2; exit 1 |
| 97 | } |
| 98 | fi |
| 99 | if cmp -s "$staging/Reasonix.exe" "$staging/reasonix-cli.exe"; then |
| 100 | echo "Reasonix.exe was overwritten by the CLI sidecar" >&2 |
| 101 | exit 1 |
| 102 | fi |
| 103 | cmp -s "$staging/reasonix-cli.exe" "$version_path/app/resources/bin/reasonix-cli-launcher.exe" || { |
| 104 | echo "reasonix-cli.exe is not the packaged CLI forwarding entry" >&2 |
| 105 | exit 1 |
| 106 | } |
| 107 | if cmp -s "$staging/reasonix-cli.exe" "$version_path/reasonix-cli.exe"; then |
| 108 | echo "Windows portable duplicated the full CLI at InstallRoot" >&2 |
| 109 | exit 1 |
| 110 | fi |
| 111 | |
| 112 | # Case-insensitive collision check among root exes. |
| 113 | actual=() |
| 114 | while IFS= read -r -d '' path; do |
| 115 | [ -f "$path" ] && [ ! -L "$path" ] || { echo "Invalid Windows root entry: $path" >&2; exit 1; } |
| 116 | name="${path##*/}" |
| 117 | case "$name" in |
| 118 | Reasonix.exe|reasonix-cli.exe) ;; |
| 119 | reasonix-launcher.exe) [ "$layout" = "legacy-dual" ] || { echo "Unexpected legacy launcher" >&2; exit 1; } ;; |
| 120 | *) echo "Unexpected Windows root executable: $name" >&2; exit 1 ;; |
| 121 | esac |
| 122 | folded=$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]') |
| 123 | if [ "${#actual[@]}" -gt 0 ]; then |
| 124 | for previous in "${actual[@]}"; do |
| 125 | previous_folded=$(printf '%s' "$previous" | tr '[:upper:]' '[:lower:]') |
| 126 | if [ "$folded" = "$previous_folded" ]; then |
| 127 | echo "Windows portable names collide case-insensitively: $previous and $name" >&2 |
| 128 | exit 1 |
| 129 | fi |
| 130 | done |
| 131 | fi |
| 132 | actual+=("$name") |
| 133 | done < <(find "$staging" -maxdepth 1 \( -iname '*.exe' -o -iname '*.dll' \) -print0) |
| 134 |