#!/usr/bin/env bash # # Baut das Add-on "Swyx Tab Bridge" und installiert es per SSH auf einem # Windows-Rechner mit Firefox. # # Ablauf: # 1. Version in manifest.json hochzaehlen (immer, ausser --no-bump) # 2. .xpi packen, optional per web-ext signieren # 3. .xpi + Install-SwyxTabBridge.ps1 per scp uebertragen # 4. Install-SwyxTabBridge.ps1 per ssh mit PowerShell ausfuehren # # Beispiele: # ./deploy/deploy-windows.sh # signieren + ausrollen, ohne Parameter # ./deploy/deploy-windows.sh --keep-firefox # laufenden Firefox nicht beenden # ./deploy/deploy-windows.sh --uninstall # ./deploy/deploy-windows.sh --xpi build/xyz-1.0.16.xpi --no-bump # # bereits signiertes Paket erneut ausrollen # ./deploy/deploy-windows.sh --host admin@ws-042 --firefox-dir auto # # anderer Rechner, Firefox selbst suchen # set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SRC_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" MANIFEST="$SRC_DIR/manifest.json" BUILD_DIR="$SRC_DIR/build" PS_SCRIPT="$SCRIPT_DIR/Install-SwyxTabBridge.ps1" EXTENSION_ID="swyx-tab-bridge@appcreation.de" # Inhalte des Add-ons - bewusst explizit, damit weder test-server/ noch build/ # noch .DS_Store im Paket landen. PACKAGE_FILES=(manifest.json background.js popup.html popup.js options.html options.js icons) # Voreinstellungen fuer den Windows-Testrechner. Der Alias 'swyx-dev' steht in # ~/.ssh/config und bringt Benutzer und Schluessel mit; --host bzw. # --firefox-dir ueberschreiben das fuer andere Rechner. DEFAULT_HOST="swyx-dev" # Ziel ist die Release-Installation. Sie liegt als Benutzerinstallation unter # %LOCALAPPDATA% und laesst sich damit ohne Administratorrechte bespielen. Der # Pfad steht fest, weil auf dem Rechner zusaetzlich die Developer Edition # installiert ist und die automatische Suche bei zwei Funden abbricht. DEFAULT_FIREFOX_DIR='C:\Users\dev\AppData\Local\Mozilla Firefox' # Firefox Release erzwingt die Signatur fest im Build. Jeder Rollout muss also # ueber AMO signiert werden, und an der Signaturpruefung wird nichts gedreht. DEFAULT_SIGN=true DEFAULT_ALLOW_UNSIGNED=false # Beendet wird nur, was aus DEFAULT_FIREFOX_DIR gestartet wurde; eine daneben # laufende zweite Installation bleibt offen. Firefox stellt die Sitzung beim # naechsten Start wieder her. DEFAULT_STOP_FIREFOX=true HOST="$DEFAULT_HOST" SSH_PORT="" MODE="Policy" BUMP="patch" FORCED_VERSION="" XPI_OVERRIDE="" REMOTE_DIR="swyx-deploy" DO_SIGN=$DEFAULT_SIGN ALLOW_UNSIGNED=$DEFAULT_ALLOW_UNSIGNED STOP_FIREFOX=$DEFAULT_STOP_FIREFOX UNINSTALL=false FIREFOX_DIR="$DEFAULT_FIREFOX_DIR" TARGET_USER="" DRY_RUN=false BUILD_ONLY=false # ------------------------------------------------------------------ Ausgabe c_step() { printf '\n\033[36m==> %s\033[0m\n' "$*"; } c_info() { printf ' %s\n' "$*"; } c_good() { printf ' \033[32m%s\033[0m\n' "$*"; } c_note() { printf ' \033[33m%s\033[0m\n' "$*"; } c_fail() { printf '\n\033[31mFEHLER: %s\033[0m\n' "$*" >&2; exit 1; } usage() { # Kopfkommentar ab Zeile 3 ausgeben, bis die erste Nicht-Kommentarzeile kommt. awk 'NR > 2 { if ($0 !~ /^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}" cat <<'EOF' Optionen: --host USER@HOST Ziel-Rechner (Default: swyx-dev aus ~/.ssh/config) --port N SSH-Port --mode policy|profile Installationsart (Default: policy; braucht Schreibrecht im Firefox-Installationsverzeichnis) --bump LEVEL major | minor | patch | build | none (Default: patch) --set-version X.Y.Z Version fest vorgeben statt hochzuzaehlen --no-bump Version unveraendert lassen (Kurzform fuer --bump none) --xpi DATEI Fertiges (z.B. signiertes) .xpi verwenden, nicht bauen --sign Per 'web-ext sign --channel=unlisted' bei AMO signieren. Voreingestellt an, weil Firefox Release nur signierte Add-ons installiert. Jeder Lauf verbraucht dabei eine Versionsnummer bei AMO --no-sign Nicht signieren (nur fuer ESR/Developer Edition sinnvoll, dann zusammen mit --allow-unsigned) --allow-unsigned Signaturpflicht auf dem Ziel abschalten (nur ESR/Dev). Voreingestellt aus --require-signed Signaturpflicht auf dem Ziel unangetastet lassen (Default) --stop-firefox Laufenden Firefox auf dem Ziel beenden, damit die Installation sofort greift. Voreingestellt an --keep-firefox Laufenden Firefox nicht anfassen --firefox-dir PFAD Firefox-Installationsverzeichnis auf dem Ziel (Default: C:\Users\dev\AppData\Local\Mozilla Firefox; "auto" laesst das Skript selbst suchen) --target-user NAME Windows-Benutzer fuer --mode profile --remote-dir NAME Uebertragungsordner im Home des SSH-Benutzers --uninstall Add-on und Policy auf dem Ziel entfernen --build-only Nur bauen, nichts uebertragen --dry-run Nur anzeigen, was passieren wuerde -h, --help Diese Hilfe Umgebungsvariablen: SSH_CMD / SCP_CMD Eigener Aufrufer statt 'ssh'/'scp', z.B. fuer Passwort-Login: SSH_CMD="sshpass -e ssh" SCP_CMD="sshpass -e scp" SSHPASS=geheim ... WEB_EXT_API_KEY / WEB_EXT_API_SECRET AMO-Zugangsdaten fuer --sign. Werden sonst aus deploy/.amo-credentials gelesen EOF exit 0 } # ------------------------------------------------------------------ Argumente while [[ $# -gt 0 ]]; do case "$1" in --host) HOST="${2:?}"; shift 2 ;; --port) SSH_PORT="${2:?}"; shift 2 ;; --mode) MODE="${2:?}"; shift 2 ;; --bump) BUMP="${2:?}"; shift 2 ;; --set-version) FORCED_VERSION="${2:?}"; shift 2 ;; --no-bump) BUMP="none"; shift ;; --xpi) XPI_OVERRIDE="${2:?}"; shift 2 ;; --sign) DO_SIGN=true; shift ;; --no-sign) DO_SIGN=false; shift ;; --allow-unsigned) ALLOW_UNSIGNED=true; shift ;; --require-signed) ALLOW_UNSIGNED=false; shift ;; --stop-firefox) STOP_FIREFOX=true; shift ;; --keep-firefox) STOP_FIREFOX=false; shift ;; --firefox-dir) FIREFOX_DIR="${2:?}"; shift 2 [[ "$FIREFOX_DIR" == "auto" ]] && FIREFOX_DIR="" ;; --target-user) TARGET_USER="${2:?}"; shift 2 ;; --remote-dir) REMOTE_DIR="${2:?}"; shift 2 ;; --uninstall) UNINSTALL=true; shift ;; --build-only) BUILD_ONLY=true; shift ;; --dry-run) DRY_RUN=true; shift ;; -h|--help) usage ;; *) c_fail "Unbekannte Option: $1 (--help fuer die Uebersicht)" ;; esac done case "$(printf '%s' "$MODE" | tr '[:upper:]' '[:lower:]')" in policy) MODE="Policy" ;; profile) MODE="Profile" ;; *) c_fail "--mode muss 'policy' oder 'profile' sein." ;; esac case "$BUMP" in major|minor|patch|build|none) ;; *) c_fail "--bump muss major, minor, patch, build oder none sein." ;; esac if ! $BUILD_ONLY && [[ -z "$HOST" ]]; then c_fail "Kein Ziel-Rechner: --host angeben." fi SSH_OPTS=() SCP_OPTS=() if [[ -n "$SSH_PORT" ]]; then SSH_OPTS+=(-p "$SSH_PORT") SCP_OPTS+=(-P "$SSH_PORT") fi # AMO-Zugangsdaten fuer --sign. Bereits gesetzte Umgebungsvariablen haben # Vorrang, damit sich der Schluessel fuer einen einzelnen Lauf uebersteuern laesst. AMO_CREDENTIALS="$SCRIPT_DIR/.amo-credentials" if [[ -f "$AMO_CREDENTIALS" ]]; then if [[ -z "${WEB_EXT_API_KEY:-}" || -z "${WEB_EXT_API_SECRET:-}" ]]; then set -a # shellcheck source=/dev/null . "$AMO_CREDENTIALS" set +a fi # Der Schluessel gilt fuer das ganze AMO-Konto - er hat auf einer # mitlesbaren Datei nichts verloren. CRED_PERMS="$(stat -f '%Lp' "$AMO_CREDENTIALS" 2>/dev/null || stat -c '%a' "$AMO_CREDENTIALS" 2>/dev/null || echo '')" if [[ -n "$CRED_PERMS" && "${CRED_PERMS: -2}" != "00" ]]; then c_note "$AMO_CREDENTIALS ist auch fuer andere lesbar - 'chmod 600' empfohlen." fi fi # Normalfall ist Anmeldung per Schluessel. Wer Passwort-Login oder einen # Jumphost braucht, haengt ueber SSH_CMD/SCP_CMD einen eigenen Aufrufer davor: # SSH_CMD="sshpass -e ssh" SCP_CMD="sshpass -e scp" SSHPASS=... ./deploy-windows.sh ... read -r -a SSH_BIN <<< "${SSH_CMD:-ssh}" read -r -a SCP_BIN <<< "${SCP_CMD:-scp}" # ------------------------------------------------------------------ Hilfsfunktionen # PowerShell-Kommando als UTF-16LE-Base64 uebergeben. Damit ist voellig egal, # ob auf dem Ziel cmd.exe oder PowerShell die Standard-Shell ist - es gibt kein # Quoting-Problem mit Leerzeichen, Backslashes oder Anfuehrungszeichen. encode_ps() { printf '%s' "$1" | iconv -f UTF-8 -t UTF-16LE | base64 | tr -d '\n' } run_remote_ps() { local command="$1" local encoded # Ohne das schiebt Windows PowerShell beim Nachladen von Modulen einen # Progress-Record als CLIXML in den Ausgabestrom. encoded="$(encode_ps "\$ProgressPreference = 'SilentlyContinue' $command")" if $DRY_RUN; then c_info "[dry-run] ${SSH_BIN[*]} ${SSH_OPTS[*]:-} $HOST powershell -EncodedCommand <${#encoded} Zeichen>" c_info "[dry-run] ${command//$'\n'/ }" return 0 fi "${SSH_BIN[@]}" ${SSH_OPTS[@]+"${SSH_OPTS[@]}"} "$HOST" powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand "$encoded" } # Ein Argument fuer die PowerShell-Kommandozeile in einfache Anfuehrungszeichen setzen. ps_quote() { printf "'%s'" "${1//\'/\'\'}" } read_manifest_version() { python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$MANIFEST" } # Version in manifest.json ersetzen, ohne die Formatierung der Datei anzutasten. write_manifest_version() { python3 - "$MANIFEST" "$1" <<'PY' import re, sys path, version = sys.argv[1], sys.argv[2] with open(path, encoding="utf-8") as handle: text = handle.read() new_text, count = re.subn( r'("version"\s*:\s*)"[^"]*"', lambda m: '%s"%s"' % (m.group(1), version), text, count=1, ) if count != 1: sys.exit("Feld 'version' nicht in %s gefunden." % path) with open(path, "w", encoding="utf-8") as handle: handle.write(new_text) PY } # Toolkit-Versionen duerfen bis zu vier Zahlen haben (1.0.0.7). 'build' zaehlt # den vierten Teil hoch und laesst die eigentliche Release-Nummer in Ruhe. next_version() { python3 - "$1" "$2" <<'PY' import sys version, level = sys.argv[1], sys.argv[2] parts = [int(p) for p in version.split(".")] while len(parts) < 3: parts.append(0) if level == "major": parts = [parts[0] + 1, 0, 0] elif level == "minor": parts = [parts[0], parts[1] + 1, 0] elif level == "patch": parts = [parts[0], parts[1], parts[2] + 1] elif level == "build": parts = parts[:3] + [(parts[3] if len(parts) > 3 else 0) + 1] print(".".join(str(p) for p in parts)) PY } # ------------------------------------------------------------------ Version VERSION="" if ! $UNINSTALL && [[ -z "$XPI_OVERRIDE" ]]; then c_step "Version" CURRENT="$(read_manifest_version)" if [[ -n "$FORCED_VERSION" ]]; then VERSION="$FORCED_VERSION" elif [[ "$BUMP" == "none" ]]; then VERSION="$CURRENT" else VERSION="$(next_version "$CURRENT" "$BUMP")" fi if [[ "$VERSION" == "$CURRENT" ]]; then c_info "unveraendert: $CURRENT" c_note "Firefox installiert ein Paket mit gleicher oder kleinerer Version nicht erneut." elif $DRY_RUN; then c_info "[dry-run] $CURRENT -> $VERSION (manifest.json bliebe unveraendert)" else write_manifest_version "$VERSION" c_good "$CURRENT -> $VERSION (manifest.json aktualisiert)" fi fi # ------------------------------------------------------------------ Paket bauen XPI="" if $UNINSTALL; then : elif [[ -n "$XPI_OVERRIDE" ]]; then [[ -f "$XPI_OVERRIDE" ]] || c_fail "Datei nicht gefunden: $XPI_OVERRIDE" XPI="$(cd "$(dirname "$XPI_OVERRIDE")" && pwd)/$(basename "$XPI_OVERRIDE")" c_step "Paket" c_info "verwende vorhandenes Paket: $XPI" else c_step "Paket bauen" for entry in "${PACKAGE_FILES[@]}"; do [[ -e "$SRC_DIR/$entry" ]] || c_fail "Im Add-on fehlt: $entry" done mkdir -p "$BUILD_DIR" if $DO_SIGN; then # Signieren erzeugt das Paket selbst - ein vorher gebautes Zip waere nur # eine zweite, unsignierte Datei mit demselben Namen. if [[ -z "${WEB_EXT_API_KEY:-}" || -z "${WEB_EXT_API_SECRET:-}" ]]; then c_fail "Keine AMO-Zugangsdaten: WEB_EXT_API_KEY/WEB_EXT_API_SECRET setzen oder in $AMO_CREDENTIALS hinterlegen." fi if $DRY_RUN; then c_info "[dry-run] npx web-ext sign --channel=unlisted (Version $VERSION)" XPI="$BUILD_DIR/swyx_tab_bridge-$VERSION.xpi" else SIGN_MARKER="$BUILD_DIR/.sign-marker" : > "$SIGN_MARKER" ( cd "$SRC_DIR" && npx --yes web-ext sign \ --channel=unlisted \ --source-dir="$SRC_DIR" \ --artifacts-dir="$BUILD_DIR" \ --ignore-files 'test-server/**' 'deploy/**' 'build/**' '**/.DS_Store' ) # Nur Dateien akzeptieren, die nach dem Start des Signierlaufs entstanden sind. XPI="$(find "$BUILD_DIR" -maxdepth 1 -name '*.xpi' -newer "$SIGN_MARKER" | head -n1)" rm -f "$SIGN_MARKER" [[ -n "$XPI" ]] || c_fail "web-ext hat kein signiertes .xpi in $BUILD_DIR hinterlassen." c_good "signiert: $XPI" fi else XPI="$BUILD_DIR/swyx_tab_bridge-$VERSION.xpi" if $DRY_RUN; then c_info "[dry-run] wuerde bauen: $XPI" else rm -f "$XPI" # -X: keine macOS-Metadaten; gezippt wird der *Inhalt*, nicht der Ordner. ( cd "$SRC_DIR" && zip -q -r -X "$XPI" "${PACKAGE_FILES[@]}" -x '*.DS_Store' '*/__MACOSX/*' ) c_good "$XPI ($(du -h "$XPI" | cut -f1 | tr -d ' '))" fi if ! $ALLOW_UNSIGNED; then c_note "Nicht signiert - Firefox Release/Beta verweigert die Installation. Entweder --sign verwenden oder auf ESR/Dev die Signaturpflicht abschalten." fi fi fi if $BUILD_ONLY; then c_step "Fertig" c_good "${XPI:-kein Paket gebaut}" exit 0 fi # ------------------------------------------------------------------ Uebertragen c_step "Verbindung zu $HOST" if $DRY_RUN; then c_info "[dry-run] ssh-Test uebersprungen" else # Auch der Test laeuft ueber -EncodedCommand: die Standard-Shell des # SSH-Benutzers ist unter Windows mal cmd.exe, mal PowerShell, und ein # blankes $env:COMPUTERNAME wuerde von der aeusseren Shell aufgeloest. REMOTE_NAME="$(run_remote_ps 'Write-Output "$env:COMPUTERNAME|$($PSVersionTable.PSVersion)"' | tr -d '\r')" \ || c_fail "SSH-Verbindung zu $HOST fehlgeschlagen." c_good "erreichbar: ${REMOTE_NAME:-unbekannt}" fi c_step "Dateien uebertragen" run_remote_ps "New-Item -ItemType Directory -Force -Path \"\$HOME\\$REMOTE_DIR\" | Out-Null" if $DRY_RUN; then c_info "[dry-run] scp $PS_SCRIPT -> $HOST:$REMOTE_DIR/" [[ -n "$XPI" ]] && c_info "[dry-run] scp $XPI -> $HOST:$REMOTE_DIR/" else "${SCP_BIN[@]}" ${SCP_OPTS[@]+"${SCP_OPTS[@]}"} -q "$PS_SCRIPT" "$HOST:$REMOTE_DIR/Install-SwyxTabBridge.ps1" c_good "Install-SwyxTabBridge.ps1" if [[ -n "$XPI" ]]; then "${SCP_BIN[@]}" ${SCP_OPTS[@]+"${SCP_OPTS[@]}"} -q "$XPI" "$HOST:$REMOTE_DIR/$(basename "$XPI")" c_good "$(basename "$XPI")" fi fi # ------------------------------------------------------------------ Ausfuehren c_step "Installation auf $HOST" PS_ARGS="" add_arg() { PS_ARGS="$PS_ARGS $1"; } add_arg "-ExtensionId $(ps_quote "$EXTENSION_ID")" if $UNINSTALL; then add_arg "-Uninstall" else add_arg "-XpiPath \"\$base\\$(basename "$XPI")\"" add_arg "-Mode $(ps_quote "$MODE")" fi [[ -n "$FIREFOX_DIR" ]] && add_arg "-FirefoxDir $(ps_quote "$FIREFOX_DIR")" [[ -n "$TARGET_USER" ]] && add_arg "-TargetUser $(ps_quote "$TARGET_USER")" $ALLOW_UNSIGNED && add_arg "-AllowUnsigned" $STOP_FIREFOX && add_arg "-StopFirefox" REMOTE_COMMAND="\$ErrorActionPreference = 'Stop' \$ProgressPreference = 'SilentlyContinue' \$base = Join-Path \$HOME '$REMOTE_DIR' try { & (Join-Path \$base 'Install-SwyxTabBridge.ps1')$PS_ARGS exit \$LASTEXITCODE } catch { Write-Host \"FEHLER: \$(\$_.Exception.Message)\" -ForegroundColor Red exit 1 }" REMOTE_LOG="$(mktemp -t swyx-deploy)" trap 'rm -f "$REMOTE_LOG"' EXIT set +e run_remote_ps "$REMOTE_COMMAND" 2>&1 | tee "$REMOTE_LOG" STATUS=${PIPESTATUS[0]} set -e # Die Login-Shell des Windows-SSH-Dienstes reicht nur 0 und "ungleich 0" durch; # aus 2 wird unterwegs 1. Massgeblich ist deshalb die Markerzeile des Skripts, # der Exitcode dient nur als Rueckfallebene. MARKER="$(grep -o 'SWYX-STATUS: [A-Z]*' "$REMOTE_LOG" | tail -n1 | awk '{print $2}')" [[ -z "$MARKER" && "$STATUS" -eq 0 ]] && MARKER="OK" c_step "Ergebnis" case "$MARKER" in OK) c_good "Erfolgreich abgeschlossen." ;; RESTART) c_good "Erfolgreich abgeschlossen." c_note "Auf $HOST muss Firefox noch neu gestartet werden." ;; *) c_fail "Das Installationsskript auf $HOST ist abgebrochen (Exitcode $STATUS)." ;; esac if [[ -n "$VERSION" ]]; then c_info "Ausgerollte Version: $VERSION" fi