#!/usr/bin/env bash
#
# Builds, packages and publishes the macOS desktop client.
#
# The counterpart of scripts/release-windows.ps1, and deliberately the same shape: run by a person, on a
# Mac that is not a CI runner, in two phases with the upload withheld until somebody has installed what
# phase one built and walked the manual checks. docs/adr/0011-android-distribution.md rule 1 puts the
# capability to ship somebody a build on a machine which is not a runner, and
# docs/adr/0013-desktop-distribution-and-updates.md explains why the token that writes a Gitea release is
# that capability: Velopack clients trust their feed and do not verify a package signature when they apply
# it, so whoever can write a release can ship an update every install runs.
#
#   1. Without --upload: builds, signs, notarizes, packs, and stops. Nothing has left this machine
#      except the notarization submission, which Apple sees and users do not.
#   2. With --upload: asks for the forge token and publishes what phase one produced. It does not
#      rebuild, so the bytes that reach users are the bytes that were installed and checked.
#
# ◆ WHAT IS DIFFERENT FROM THE WINDOWS SCRIPT, AND WHY.
#
# Signing is not optional here. On Windows an unsigned installer costs a SmartScreen dialog once per
# user, which is why that script has no --signParams and says so. On macOS an un-notarized download is
# refused outright by Gatekeeper — not warned about, refused — so the Developer ID certificate and the
# notarization round trip are the price of the package being installable at all, not an improvement to
# be bought later.
#
# ◆ CREDENTIALS COME FROM THE KEYCHAIN AND THE ENVIRONMENT, NOT FROM THIS FILE.
#
# Three values are read from the environment, and none of them is itself a secret — they name things the
# keychain holds, and the keychain is what guards the private key and the App Store Connect credentials:
#
#   DODOSSH_SIGN_APP_IDENTITY      e.g. "Developer ID Application: DodoTech (TEAMID)"
#   DODOSSH_SIGN_INSTALL_IDENTITY  e.g. "Developer ID Installer: DodoTech (TEAMID)"
#   DODOSSH_NOTARY_PROFILE         the profile name given to `xcrun notarytool store-credentials`
#
# `security find-identity -v -p codesigning` lists the first two exactly as codesign wants them. The
# third is created once per machine:
#
#   xcrun notarytool store-credentials DodoSSH \
#     --apple-id you@example.com --team-id TEAMID --password <app-specific-password>
#
# The forge token is the one real secret, and it is prompted for rather than read from a file or the
# environment, and only in the phase that needs it — for the reason the Windows script gives: the fewer
# minutes a credential that can publish an update spends in a shell's memory the better.
#
# Usage:
#   bash scripts/release-macos.sh
#   bash scripts/release-macos.sh --upload
#   bash scripts/release-macos.sh --skip-tests

set -euo pipefail

UPLOAD=0
SKIP_TESTS=0

for arg in "$@"; do
    case "$arg" in
        --upload)     UPLOAD=1 ;;
        --skip-tests) SKIP_TESTS=1 ;;
        *)
            echo "Unknown argument: $arg" >&2
            echo "Usage: bash scripts/release-macos.sh [--upload] [--skip-tests]" >&2
            exit 1
            ;;
    esac
done

# ---- The contract with every installed client ---------------------------------------------------------

# Velopack's identity for this application, and it is effectively irreversible for the reasons the Windows
# script states — it is what an installed client matches an update against.
#
# ◆ THE SAME PACK ID AS WINDOWS, AND ON THIS PLATFORM IT IS VISIBLE.
#
# vpk names the bundle after the pack id, so this produces DodoSSH.Desktop.app rather than DodoSSH.app,
# and that is what somebody sees in /Applications. It is kept anyway, because the alternative is worse:
# a pack id of DodoSSH would put Velopack's install and its uninstall on ~/Library/Application Support/
# DodoSSH, which is exactly where ClientPaths keeps the encrypted cache, the outbox of changes not yet
# pushed and the device key. Sharing that directory would mean an uninstall silently taking a user's
# un-synced work with it. The same reasoning, and the same conclusion, as the Windows script.
#
# What a person actually reads is CFBundleDisplayName, which build/macos/Info.plist.template sets to
# DodoSSH. So the bundle keeps the id and the Dock shows the product.
PACK_ID='DodoSSH.Desktop'
PACK_TITLE='DodoSSH'
PACK_AUTHORS='DodoTech'

# The project's own forge. Never a DodoSSH deployment — ADR 0011 rule 2. The same URL is a constant in
# VelopackUpdateChannel, and the two have to agree or the client polls somewhere nothing is published.
# The owner is part of it: Gitea left a 301 at the old organisation's path, which a GET follows and an
# upload does not.
REPO_URL='https://git.dodotech.cloud/DodoTech-Public/DodoSSH'

# A contract with VelopackUpdateChannel.MacReleaseChannel. Velopack's macOS default is also "osx", so
# leaving it unsaid on both sides would work — but unsaid here and stated there is how a feed goes quiet
# with no error at all: the client checks, finds nothing, and reports itself up to date forever.
CHANNEL='osx'

# ◆ ARM64 ONLY, AND THAT IS A DECISION RATHER THAN AN OVERSIGHT.
#
# Velopack keys a channel to one architecture, so shipping Intel too means a second channel, a second
# publish, a second set of deltas and a second thing to keep in step with the client's channel picker.
# That is all affordable. What is not currently affordable is testing it: nobody here has an Intel Mac,
# and docs/manual-checks.md exists because this project does not ship desktop builds no one has run.
# An x64 package built blind and published beside a checked arm64 one would be the only artefact in this
# repository that reached users unverified.
#
# Adding it later is this constant, a second channel name in VelopackUpdateChannel, and a picker keyed on
# RuntimeInformation.ProcessArchitecture — which reports X64 for a build running under Rosetta, so an
# Intel build correctly stays on the Intel feed. The work is small; the check is the part that is missing.
RUNTIME='osx-arm64'

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PROJECT="$REPO_ROOT/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj"
SOLUTION="$REPO_ROOT/DodoSSH.slnx"
PUBLISH_DIR="$REPO_ROOT/publish/$RUNTIME"
RELEASES_DIR="$REPO_ROOT/Releases"
ICON="$REPO_ROOT/src/DodoSSH.Client.App/Assets/dodossh.icns"
ENTITLEMENTS="$REPO_ROOT/build/macos/DodoSSH.entitlements"
PLIST_TEMPLATE="$REPO_ROOT/build/macos/Info.plist.template"

write_step() { printf '\n\033[36m==> %s\033[0m\n' "$1"; }
stop_with()  { printf '\n\033[31m%s\033[0m\n' "$1" >&2; exit 1; }

# ---- Is this machine able to do the job at all? -------------------------------------------------------

if [ "$(uname -s)" != 'Darwin' ]; then
    # codesign, notarytool and stapler are Apple tooling and exist nowhere else. The build and even the
    # .app bundle cross-compile fine from Windows or Linux — `vpk [osx] bundle` does exactly that, and
    # ci.yml uses it to prove the bundle still builds — but a signed, notarized, installable package
    # cannot be produced anywhere but here.
    stop_with 'This builds a signed macOS package and has to run on macOS.'
fi

for tool in dotnet git xcrun codesign; do
    command -v "$tool" >/dev/null 2>&1 || stop_with "$tool is not on PATH."
done

# Checked before anything is built rather than at the step that uses them. Notarization is the last thing
# this script does and the slowest, and discovering there that a profile name was never exported means
# throwing away a full build and test run.
for required in DODOSSH_SIGN_APP_IDENTITY DODOSSH_SIGN_INSTALL_IDENTITY DODOSSH_NOTARY_PROFILE; do
    if [ -z "${!required-}" ]; then
        stop_with "$required is not set. See the header of this script for what the three are and how to make them."
    fi
done

cd "$REPO_ROOT"

# ---- What is being released ---------------------------------------------------------------------------

# Restored before the version is read, and both halves are load-bearing — the same two traps the Windows
# script documents. -t:MinVer, because -getProperty alone evaluates the project and runs no targets, while
# MinVer sets Version from inside one, so the read would answer the SDK's default 1.0.0 regardless of the
# tag. And a restore first, because naming a target that arrives with a package fails MSB4057 on a clean
# clone where obj/ has no MinVer targets to import yet.
write_step 'Restoring the desktop head, so the version can be read'
dotnet restore "$PROJECT" --locked-mode || stop_with 'Restore failed.'

VERSION="$(dotnet msbuild "$PROJECT" -getProperty:Version -t:MinVer -nologo | tr -d '[:space:]')"
[ -n "$VERSION" ] || stop_with 'Could not read the version from MSBuild.'

TAG="v$VERSION"

# Apple's two version keys take one to three dot-separated integers and nothing else, so a prerelease
# version has to have its suffix removed before it reaches the plist. 1.2.3-rc.1 becomes 1.2.3.
#
# The full version, suffix and all, is what vpk packs and what the release index carries, so the updater
# still tells an rc from the release it precedes. These two keys are for Finder and Gatekeeper, which
# care that the string parses and not what it says. See build/macos/Info.plist.template.
PLIST_VERSION="${VERSION%%-*}"
PLIST_VERSION="${PLIST_VERSION%%+*}"

write_step "DodoSSH $VERSION  ($PACK_ID, channel $CHANNEL, $RUNTIME)"

# ---- Phase 2: publish what phase 1 built --------------------------------------------------------------

if [ "$UPLOAD" -eq 1 ]; then
    # The installer package is the artefact a person downloads, so its absence is the honest test of
    # whether phase one ever ran. A directory holding only a .nupkg is a pack that failed part way.
    if ! ls "$RELEASES_DIR"/*.pkg >/dev/null 2>&1; then
        stop_with "Nothing to upload: $RELEASES_DIR has no .pkg. Run this without --upload first."
    fi

    echo "About to publish the contents of $RELEASES_DIR to $REPO_URL as $TAG."
    echo 'Only do this once you have installed it and walked Phase 18 of docs/manual-checks.md.'

    # -s so the token is never echoed and never lands in the shell's history.
    printf 'Gitea token (write:repository): '
    read -r -s TOKEN
    echo

    [ -n "$TOKEN" ] || stop_with 'No token given.'

    # --merge because Gitea already has a release entry for the pushed tag — and on this platform it may
    # also already hold the Windows package for the same tag, which is the case --merge is really doing
    # the work for: without it the second platform to publish a given version fails on a release that
    # exists, and with it the two sit side by side under one tag. --channel keeps the indexes apart.
    UPLOAD_ARGS=(
        upload gitea
        --repoUrl "$REPO_URL"
        --token "$TOKEN"
        --outputDir "$RELEASES_DIR"
        --channel "$CHANNEL"
        --releaseName "$TAG"
        --tag "$TAG"
        --merge
        --publish
    )

    # Mirrors the rule the docker image job and the Windows script already apply to the same tag, so a
    # release candidate is a prerelease in every channel or in none.
    case "$VERSION" in
        *-*) UPLOAD_ARGS+=(--pre) ;;
    esac

    write_step 'Uploading'
    dotnet vpk "${UPLOAD_ARGS[@]}" || stop_with 'vpk upload failed.'

    write_step "Published $TAG."
    exit 0
fi

# ---- Phase 1: build, sign, notarize, pack -------------------------------------------------------------

[ -z "$(git status --porcelain)" ] || stop_with 'The working tree is not clean. A release is cut from a commit, not from a desk.'

HEAD_TAG="$(git describe --exact-match --tags HEAD 2>/dev/null || true)"
[ -n "$HEAD_TAG" ] || stop_with "HEAD is not tagged. Tag it $TAG first, or change the version and tag that."

# Cannot happen while MinVer is deriving the version from this very tag, and checked anyway: the day
# somebody pins a version by hand this is the guard that notices.
[ "$HEAD_TAG" = "$TAG" ] || stop_with "HEAD is tagged $HEAD_TAG but the computed version is $VERSION."

write_step 'Restoring tools'
dotnet tool restore || stop_with 'dotnet tool restore failed.'

write_step 'Restoring packages (locked, exactly as CI does)'
dotnet restore "$SOLUTION" --locked-mode || stop_with 'Restore failed. A lock file that only works on Linux fails here.'

write_step 'Building'
dotnet build "$SOLUTION" --no-restore --configuration Release || stop_with 'Build failed.'

if [ "$SKIP_TESTS" -eq 0 ]; then
    # The end-to-end suite starts containers and takes minutes. It is run here anyway rather than taken
    # on trust from CI, because a tag is the one build nobody is watching — and on this platform there is
    # a second reason: CI has no macOS runner, so this is the only place the suite ever runs on a Mac at
    # all. Everything docs/platform-flags.md lists as unverified on macOS is verified here or nowhere.
    write_step 'Testing'
    dotnet test "$SOLUTION" --no-build --configuration Release || stop_with 'Tests failed.'
fi

write_step "Publishing $RUNTIME"
rm -rf "$PUBLISH_DIR"

# Self-contained, and not single-file, for the reasons the Windows script gives: the native libraries ship
# per RID and a self-extracting bundle breaks delta updates.
#
# RestoreLockedMode=false, and the lock files put back straight afterwards. A RID-specific publish resolves
# a graph the committed lock files do not describe, because they are deliberately kept RID-free —
# declaring a RID on the head writes a net10.0/<rid> target into every project it references transitively,
# including DodoSSH.Contracts and DodoSSH.Crypto, and the API's Dockerfile then restores those with no RID
# under locked mode and fails NU1004. Packaging the desktop client would have broken the server's image
# build. The gate that matters is the locked solution restore above, which is untouched.
dotnet publish "$PROJECT" \
    --configuration Release \
    --runtime "$RUNTIME" \
    --self-contained true \
    --output "$PUBLISH_DIR" \
    -p:RestoreLockedMode=false \
    || stop_with 'Publish failed.'

# An unlocked restore rewrites the lock files it walked. Left there, the next commit would carry exactly
# the change that breaks the image build. Safe to do bluntly because this script refuses to run on a dirty
# tree, so anything modified here is its own.
git checkout -- '*packages.lock.json' || stop_with 'Could not restore the lock files after publishing.'

# Checked rather than assumed. A publish directory without Velopack.dll would pack into an installer for an
# application that never checks for updates — which looks completely normal until the next release goes out
# and nobody receives it.
for required in DodoSSH Velopack.dll; do
    [ -e "$PUBLISH_DIR/$required" ] || stop_with "$required is missing from $PUBLISH_DIR."
done

echo "  $(du -sh "$PUBLISH_DIR" | cut -f1) in $(find "$PUBLISH_DIR" -type f | wc -l | tr -d ' ') files"

# ---- Signing the native libraries, before vpk signs anything ------------------------------------------

# ◆ THIS LOOP IS WHY NOTARIZATION SUCCEEDS, AND IT LOOKS REDUNDANT.
#
# vpk signs the finished bundle itself, with `codesign -f -v --timestamp --options runtime --entitlements
# <file> --deep`, and --deep is documented by Apple as the wrong way to sign nested code. Apple's guidance
# is inside-out: sign each nested binary first, then the bundle around it. --deep does the reverse in one
# pass and applies the outer entitlements to everything it touches.
#
# In practice --deep alone is where the failure recorded in docs/platform-flags.md comes from — a
# notarization rejection that does not name the offending file, on a submission that took its time getting
# there. Signing each dylib properly first means vpk's pass has nothing left to get wrong, and re-signing
# an already correctly signed binary with -f is a no-op in effect.
#
# No --entitlements here, and that is the difference that matters. Entitlements belong on the main
# executable; a dylib carrying allow-jit is at best meaningless and at worst a rejection.
write_step 'Signing native libraries'

# createdump is a Mach-O executable the runtime ships and it is signed like the libraries: a nested
# executable that is not signed fails notarization exactly as an unsigned dylib does, and it is the one
# people forget because it has no extension to grep for.
NATIVE_COUNT=0
while IFS= read -r -d '' binary; do
    codesign --force --verbose=0 --timestamp --options runtime \
        --sign "$DODOSSH_SIGN_APP_IDENTITY" "$binary" \
        || stop_with "codesign failed on $binary"
    NATIVE_COUNT=$((NATIVE_COUNT + 1))
done < <(find "$PUBLISH_DIR" \( -name '*.dylib' -o -name 'createdump' \) -type f -print0)

[ "$NATIVE_COUNT" -gt 0 ] || stop_with "No native binaries found under $PUBLISH_DIR, which cannot be right for a self-contained publish."
echo "  signed $NATIVE_COUNT native binaries"

# ---- The bundle's Info.plist --------------------------------------------------------------------------

# Rendered rather than committed, because vpk copies a custom plist verbatim and substitutes nothing —
# so a committed one would carry whatever version it was written with into every release afterwards.
# See the header of build/macos/Info.plist.template.
write_step "Rendering Info.plist for $PLIST_VERSION"
RENDERED_PLIST="$(mktemp -t dodossh-plist)"
trap 'rm -f "$RENDERED_PLIST"' EXIT

sed "s/@VERSION@/$PLIST_VERSION/g" "$PLIST_TEMPLATE" > "$RENDERED_PLIST"

# The placeholder is the whole mechanism, so its absence is checked rather than hoped for. A template
# somebody edited into a literal version would otherwise sail through and pin every future release to it.
grep -q '@VERSION@' "$PLIST_TEMPLATE" || stop_with "$PLIST_TEMPLATE has no @VERSION@ placeholder left in it."
! grep -q '@VERSION@' "$RENDERED_PLIST" || stop_with 'Substitution into the rendered Info.plist did not take.'

mkdir -p "$RELEASES_DIR"

# The previous release, so a delta can be built against it. Tolerated when it finds nothing: the first
# macOS release has no predecessor, and a hard failure here would make cutting it impossible.
write_step 'Fetching the previous release, for deltas'
if ! dotnet vpk download gitea --repoUrl "$REPO_URL" --outputDir "$RELEASES_DIR" --channel "$CHANNEL"; then
    echo '  Nothing came down. This package will be full-only, which is right for a first release.'
fi

# ---- Pack, sign, notarize, staple ---------------------------------------------------------------------

# One command does the rest, and it is worth knowing what it is doing on your behalf, because the slow
# part is not local: it builds the .app from the published files, signs it with the Developer ID
# certificate and the entitlements below, submits it to Apple with `xcrun notarytool submit --wait`,
# staples the resulting ticket to the package, and then builds the .pkg installer and the release index.
#
# The notarization wait is the reason this step can take a quarter of an hour and occasionally much
# longer — it is a queue at Apple, not a computation here, and vpk's own message says so.
#
# --signInstallIdentity is a different certificate from --signAppIdentity, and the pair is not
# interchangeable: "Developer ID Application" signs the bundle, "Developer ID Installer" signs the .pkg.
# Passing one where the other belongs fails with a message about an identity that cannot be found, which
# reads like a keychain problem rather than like the wrong certificate.
write_step 'Packing, signing and notarizing (the notarization wait is Apple queueing, not this machine)'

dotnet vpk pack \
    --packId "$PACK_ID" \
    --packVersion "$VERSION" \
    --packDir "$PUBLISH_DIR" \
    --packTitle "$PACK_TITLE" \
    --packAuthors "$PACK_AUTHORS" \
    --mainExe 'DodoSSH' \
    --icon "$ICON" \
    --plist "$RENDERED_PLIST" \
    --entitlements "$ENTITLEMENTS" \
    --signAppIdentity "$DODOSSH_SIGN_APP_IDENTITY" \
    --signInstallIdentity "$DODOSSH_SIGN_INSTALL_IDENTITY" \
    --notaryProfile "$DODOSSH_NOTARY_PROFILE" \
    --runtime "$RUNTIME" \
    --channel "$CHANNEL" \
    --outputDir "$RELEASES_DIR" \
    || stop_with 'vpk pack failed.'

# ---- Did the notarization actually take? --------------------------------------------------------------

# Asked rather than assumed, and this is the check worth having above all the others. A package whose
# ticket did not staple is indistinguishable from a good one on the machine that built it — the Mac that
# signed something trusts it locally — and reveals itself only on somebody else's machine, as a refusal
# to open at all. spctl assesses it the way Gatekeeper will on a machine that has never seen this
# certificate.
write_step 'Verifying the notarization the way another Mac will'

PKG="$(ls -t "$RELEASES_DIR"/*.pkg 2>/dev/null | head -n 1)"
[ -n "$PKG" ] || stop_with 'vpk pack reported success but produced no .pkg.'

if ! spctl --assess --type install --verbose=4 "$PKG"; then
    stop_with "Gatekeeper rejects $PKG. It is signed but the notarization ticket is missing or stale; do not upload it."
fi

xcrun stapler validate "$PKG" || stop_with "The notarization ticket is not stapled to $PKG."

write_step 'Built, notarized, and deliberately not uploaded'

ls -lh "$RELEASES_DIR" | tail -n +2

cat <<EOF

Next:
  1. Install the .pkg above and walk Phase 18 of docs/manual-checks.md.
  2. Then: bash scripts/release-macos.sh --upload
EOF
