From 8e245dfe6109d252c62a7cf4d20adae3a4bb702e Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Wed, 27 May 2026 11:26:16 -0700 Subject: [PATCH] feat: cross-platform installer (Windows port) - install.py / uninstall.py: full Python installer for Linux, macOS, Windows - detect_platform.py: shared Inkscape config path detection - Windows config path: %APPDATA%\inkscape (native/winget/scoop) or %LOCALAPPDATA%\Packages\org.inkscape.Inkscape* (MS Store) - Windows desktop: Start Menu .lnk via PowerShell WScript.Shell COM object - macOS: XDG / ~/Library/Application Support detection, no desktop step - install.sh / uninstall.sh: reduced to one-line exec python3 wrappers - eliminates SC1091 (source not followed) and SC2155 (declare+assign) - scripts/detect_platform.sh: export INKSCAPE_CONFIG / INKSCAPE_INSTALL_METHOD - fixes SC2034 (variables appear unused to shellcheck) - assets/illuscape.ico: pre-rendered 16/32/48/64/128/256px ICO (ImageMagick) - tests/test_install.py: pytest integration tests replacing test_install.bats - cross-platform fake inkscape injected via PATH - 12 tests covering backup, palettes, templates, prefs, uninstall restore - ci.yml: add windows-latest matrix leg; shellcheck/inkscape Linux-only; bats removed in favour of pytest integration tests; 27 tests total --- .github/workflows/ci.yml | 27 ++-- assets/illuscape.ico | Bin 0 -> 106149 bytes install.py | 288 +++++++++++++++++++++++++++++++++++++ install.sh | 183 +---------------------- scripts/detect_platform.py | 82 +++++++++++ scripts/detect_platform.sh | 12 +- tests/test_install.py | 222 ++++++++++++++++++++++++++++ uninstall.py | 151 +++++++++++++++++++ uninstall.sh | 74 +--------- 9 files changed, 772 insertions(+), 267 deletions(-) create mode 100644 assets/illuscape.ico create mode 100644 install.py create mode 100644 scripts/detect_platform.py create mode 100644 tests/test_install.py create mode 100644 uninstall.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01dcfd1..3d747a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,10 +7,13 @@ on: jobs: test: - runs-on: ubuntu-${{ matrix.ubuntu }} + runs-on: ${{ matrix.os }} strategy: matrix: - ubuntu: ["22.04", "24.04"] + include: + - os: ubuntu-22.04 + - os: ubuntu-24.04 + - os: windows-latest steps: - uses: actions/checkout@v4 @@ -20,19 +23,25 @@ jobs: with: python-version: "3.11" - - name: Install test deps + - name: Install test deps (Linux) + if: runner.os == 'Linux' run: | pip install pytest - sudo apt-get install -y bats shellcheck inkscape + sudo apt-get install -y shellcheck inkscape - - name: Python unit tests - run: pytest tests/test_merge_prefs.py -v + - name: Install test deps (Windows) + if: runner.os == 'Windows' + run: pip install pytest - - name: Shellcheck + - name: Shellcheck (Linux only) + if: runner.os == 'Linux' run: | shellcheck install.sh shellcheck uninstall.sh shellcheck scripts/detect_platform.sh - - name: Bats integration tests - run: bats tests/test_install.bats + - name: Python unit tests + run: pytest tests/test_merge_prefs.py -v + + - name: Python integration tests + run: pytest tests/test_install.py -v diff --git a/assets/illuscape.ico b/assets/illuscape.ico new file mode 100644 index 0000000000000000000000000000000000000000..bf2e81d3a053529181e11b8c4cad6ce9eb754843 GIT binary patch literal 106149 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 literal 0 HcmV?d00001 diff --git a/install.py b/install.py new file mode 100644 index 0000000..dae54b9 --- /dev/null +++ b/install.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +""" +Illuscape installer — cross-platform (Linux, Windows, macOS). + +Usage: + python3 install.py [--preset=cc|cs6] [--yes] + ./install.sh [--preset=cc|cs6] [--yes] (Linux / macOS) +""" +from __future__ import annotations + +import argparse +import datetime +import os +import platform +import re +import shutil +import subprocess +import sys +from pathlib import Path + +# Ensure scripts/ is importable regardless of cwd +SCRIPT_DIR = Path(__file__).parent +sys.path.insert(0, str(SCRIPT_DIR / "scripts")) + +from detect_platform import detect_config # noqa: E402 + +ICON_SIZES = (16, 32, 48, 64, 128, 256, 512) + + +# ── Dependency check ────────────────────────────────────────────────────────── + +def check_deps(auto_yes: bool) -> None: + """Verify Inkscape is present and meets the minimum version.""" + if not shutil.which("inkscape"): + sys.exit("✗ Inkscape is not installed. Install Inkscape first, then run Illuscape.") + + try: + raw = subprocess.run( + ["inkscape", "--version"], + capture_output=True, text=True, timeout=10, + ).stdout + except (FileNotFoundError, subprocess.TimeoutExpired): + return # cannot check version — proceed anyway + + match = re.search(r"(\d+)\.(\d+)", raw) + if match: + major, minor = int(match.group(1)), int(match.group(2)) + if major < 1 or (major == 1 and minor < 2): + version = f"{major}.{minor}" + print(f"⚠ Inkscape {version} detected. Illuscape targets Inkscape 1.2+.") + print(" Some settings may not apply correctly. Continue anyway? [y/N]") + if not auto_yes and input().strip().lower() != "y": + sys.exit(0) + + +# ── Backup ──────────────────────────────────────────────────────────────────── + +def backup_config(config_path: Path) -> None: + """Copy the config directory to a timestamped backup; idempotent on repeat runs.""" + timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + backup_dir = Path(str(config_path) + f".bak-illuscape-{timestamp}") + + existing = sorted(config_path.parent.glob(config_path.name + ".bak-illuscape-*")) + if existing: + print("→ Backup already exists — skipping (idempotent)") + return + + if config_path.is_dir(): + shutil.copytree(config_path, backup_dir) + print(f"→ Backed up to: {backup_dir}") + else: + print("→ No existing config to back up (fresh install)") + + +# ── Preset selection ────────────────────────────────────────────────────────── + +def choose_preset(preset: str, auto_yes: bool) -> str: + """Prompt the user for CC / CS6 unless preset is already provided.""" + if preset: + return preset + + print() + print("Which Illustrator era are you coming from?") + print(" [1] CC — current Creative Cloud (default)") + print(" [2] CS6 — last perpetual license") + print("Choice [1]: ", end="", flush=True) + if auto_yes: + print("1 (auto)") + return "cc" + choice = input().strip() + return "cs6" if choice == "2" else "cc" + + +# ── Preferences merge ───────────────────────────────────────────────────────── + +def merge_prefs(config_path: Path, preset: str) -> None: + """Invoke merge_prefs.py to upsert the preferences patch into Inkscape's prefs.""" + subprocess.run( + [ + sys.executable, + str(SCRIPT_DIR / "scripts/merge_prefs.py"), + "--prefs", str(config_path / "preferences.xml"), + "--patch", str(SCRIPT_DIR / "config/preferences-patch.xml"), + "--preset", preset, + ], + check=True, + ) + + +# ── Config file copy ────────────────────────────────────────────────────────── + +def copy_files(config_path: Path, preset: str) -> None: + """Copy keys, palettes, templates, and symbols into the Inkscape config dir.""" + for subdir in ("keys", "palettes", "templates", "symbols", "splashscreens"): + (config_path / subdir).mkdir(parents=True, exist_ok=True) + + shutil.copy2( + SCRIPT_DIR / f"config/keys/illustrator-{preset}.xml", + config_path / "keys/", + ) + for palette in (SCRIPT_DIR / "config/palettes").glob("*.gpl"): + shutil.copy2(palette, config_path / "palettes/") + for template in (SCRIPT_DIR / "config/templates").glob("*.svg"): + shutil.copy2(template, config_path / "templates/") + for symbol in (SCRIPT_DIR / "config/symbols").glob("*.svg"): + shutil.copy2(symbol, config_path / "symbols/") + + print("→ Config files copied") + + +# ── Desktop integration ─────────────────────────────────────────────────────── + +def install_desktop(config_path: Path, install_method: str) -> None: + """Install a desktop launcher / Start Menu shortcut for the current platform.""" + system = platform.system() + if system == "Linux" and install_method == "native": + _install_linux_desktop(config_path) + elif system == "Windows": + _install_windows_desktop(config_path) + # macOS: Inkscape.app handles its own dock icon; no extra step needed. + + +def _install_linux_desktop(config_path: Path) -> None: + app_dir = Path.home() / ".local/share/applications" + icon_src = SCRIPT_DIR / "assets/illuscape.svg" + splash_src = SCRIPT_DIR / "assets/splash.svg" + + app_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(SCRIPT_DIR / "assets/org.inkscape.Inkscape.desktop", app_dir) + + for size in ICON_SIZES: + icon_dir = Path.home() / f".local/share/icons/hicolor/{size}x{size}/apps" + icon_dir.mkdir(parents=True, exist_ok=True) + icon_out = icon_dir / "illuscape.png" + if _rsvg_convert(icon_src, icon_out, size, size): + continue + if _inkscape_export(icon_src, icon_out, size, size): + continue + print(f"⚠ Could not rasterize icon at {size}px (install rsvg-convert for icons)") + + splash_out = config_path / "splashscreens/illuscape-splash.png" + _rsvg_convert(splash_src, splash_out, 600, 400) + + subprocess.run( + ["update-desktop-database", str(app_dir)], + capture_output=True, + ) + print("→ Desktop launcher installed") + + +def _install_windows_desktop(config_path: Path) -> None: + """ + Create a Start Menu .lnk shortcut via PowerShell WScript.Shell COM object. + The ICO is copied to the config dir so it stays with the config. + """ + ico_src = SCRIPT_DIR / "assets/illuscape.ico" + ico_dst = config_path / "illuscape.ico" + + if ico_src.exists(): + shutil.copy2(ico_src, ico_dst) + + start_menu = ( + Path(os.environ.get("APPDATA", "")) + / "Microsoft/Windows/Start Menu/Programs" + ) + lnk_path = start_menu / "Illuscape.lnk" + inkscape_exe = shutil.which("inkscape") or "inkscape.exe" + + # Build a PowerShell one-liner using WScript.Shell + ps_lines = [ + "$ws = New-Object -ComObject WScript.Shell", + f"$s = $ws.CreateShortcut('{lnk_path}')", + f"$s.TargetPath = '{inkscape_exe}'", + "$s.Description = 'Inkscape with Illustrator-style config'", + ] + if ico_dst.exists(): + ps_lines.append(f"$s.IconLocation = '{ico_dst}'") + ps_lines.append("$s.Save()") + + try: + subprocess.run( + ["powershell", "-NoProfile", "-Command", "; ".join(ps_lines)], + check=True, + capture_output=True, + text=True, + ) + print("→ Start Menu shortcut installed") + except (FileNotFoundError, subprocess.CalledProcessError) as exc: + print(f"⚠ Could not create Start Menu shortcut: {exc}") + print(" You can create one manually pointing to inkscape.exe.") + + +# ── Icon rasterization helpers ──────────────────────────────────────────────── + +def _rsvg_convert(src: Path, dst: Path, w: int, h: int) -> bool: + """Rasterize *src* SVG to *dst* PNG using rsvg-convert. Returns True on success.""" + if not shutil.which("rsvg-convert"): + return False + result = subprocess.run( + ["rsvg-convert", "-w", str(w), "-h", str(h), str(src), "-o", str(dst)], + capture_output=True, + ) + return result.returncode == 0 + + +def _inkscape_export(src: Path, dst: Path, w: int, h: int) -> bool: + """ + Rasterize *src* SVG using Inkscape 1.x export flags. + (--export-png was removed in Inkscape 1.0) + """ + result = subprocess.run( + [ + "inkscape", + f"--export-filename={dst}", + "--export-type=png", + f"--export-width={w}", + f"--export-height={h}", + str(src), + ], + capture_output=True, + ) + return result.returncode == 0 + + +# ── CLI ─────────────────────────────────────────────────────────────────────── + +def _parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="Illuscape — Inkscape with Illustrator feel") + p.add_argument( + "--preset", + choices=["cc", "cs6"], + default="", + help="Illustrator shortcut era: cc (default) or cs6", + ) + p.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompts") + return p.parse_args() + + +def main() -> None: + args = _parse_args() + + print("╔══════════════════════════════╗") + print("║ Illuscape Installer ║") + print("╚══════════════════════════════╝") + print() + + check_deps(args.yes) + config_path, install_method = detect_config() + print(f"→ Inkscape config: {config_path} ({install_method})") + + backup_config(config_path) + preset = choose_preset(args.preset, args.yes) + print(f"→ Using preset: {preset}") + + merge_prefs(config_path, preset) + copy_files(config_path, preset) + install_desktop(config_path, install_method) + + print() + print(f"✓ Illuscape installed (preset: {preset})") + print() + print(" Open Inkscape to start using your Illustrator-style workspace.") + print(" To uninstall: python3 uninstall.py (or ./uninstall.sh on Linux/macOS)") + print() + + +if __name__ == "__main__": + main() diff --git a/install.sh b/install.sh index 538337a..1c56f4e 100755 --- a/install.sh +++ b/install.sh @@ -1,185 +1,6 @@ #!/usr/bin/env bash -# Illuscape installer +# Illuscape installer — thin wrapper around install.py # Usage: ./install.sh [--preset=cc|cs6] [--yes] set -euo pipefail - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PRESET="" -AUTO_YES=0 - -# ── Parse args ────────────────────────────────────────────────────────────── -for arg in "$@"; do - case "$arg" in - --preset=cc|--preset=cs6) PRESET="${arg#--preset=}" ;; - --yes|-y) AUTO_YES=1 ;; - --help|-h) - echo "Usage: ./install.sh [--preset=cc|cs6] [--yes]" - echo " --preset=cc Illustrator CC shortcuts (default)" - echo " --preset=cs6 Illustrator CS6 shortcuts" - echo " --yes Skip confirmation prompts" - exit 0 ;; - *) echo "Unknown argument: $arg"; exit 1 ;; - esac -done - -# ── Check dependencies ─────────────────────────────────────────────────────── -check_deps() { - if ! command -v inkscape &>/dev/null; then - echo "✗ Inkscape is not installed. Install Inkscape first, then run Illuscape." - exit 1 - fi - - local version - version=$(inkscape --version 2>/dev/null | grep -oP '\d+\.\d+' | head -1) - local major minor - IFS='.' read -r major minor <<< "$version" - # Skip version check if regex couldn't match (non-standard version string) - if [[ -n "$major" ]] && { (( major < 1 )) || (( major == 1 && minor < 2 )); }; then - echo "⚠ Inkscape $version detected. Illuscape targets Inkscape 1.2+." - echo " Some settings may not apply correctly. Continue anyway? [y/N]" - [[ $AUTO_YES == 1 ]] || { read -r ans; [[ "$ans" =~ ^[Yy]$ ]] || exit 0; } - fi - - if ! command -v python3 &>/dev/null; then - echo "✗ python3 is required. Install it and try again." - exit 1 - fi -} - -# ── Detect platform ────────────────────────────────────────────────────────── -detect_config() { - source "$SCRIPT_DIR/scripts/detect_platform.sh" - echo "→ Inkscape config: $INKSCAPE_CONFIG ($INKSCAPE_INSTALL_METHOD)" -} - -# ── Backup ─────────────────────────────────────────────────────────────────── -backup_config() { - local backup_dir="${INKSCAPE_CONFIG}.bak-illuscape-$(date +%Y%m%d-%H%M%S)" - - # If any illuscape backup already exists, skip (idempotent) - if ls "${INKSCAPE_CONFIG}".bak-illuscape-* &>/dev/null; then - echo "→ Backup already exists — skipping (idempotent)" - return 0 - fi - - if [[ -d "$INKSCAPE_CONFIG" ]]; then - cp -r "$INKSCAPE_CONFIG" "$backup_dir" - echo "→ Backed up to: $backup_dir" - else - echo "→ No existing config to back up (fresh install)" - fi -} - -# ── Prompt for preset ──────────────────────────────────────────────────────── -choose_preset() { - [[ -n "$PRESET" ]] && return 0 - echo "" - echo "Which Illustrator era are you coming from?" - echo " [1] CC — current Creative Cloud (default)" - echo " [2] CS6 — last perpetual license" - printf "Choice [1]: " - if [[ $AUTO_YES == 1 ]]; then - echo "1 (auto)" - PRESET="cc" - return 0 - fi - read -r choice - case "${choice:-1}" in - 2) PRESET="cs6" ;; - *) PRESET="cc" ;; - esac - echo "→ Using preset: $PRESET" -} - -# ── Merge preferences ──────────────────────────────────────────────────────── -merge_prefs() { - local prefs_file="$INKSCAPE_CONFIG/preferences.xml" - python3 "$SCRIPT_DIR/scripts/merge_prefs.py" \ - --prefs "$prefs_file" \ - --patch "$SCRIPT_DIR/config/preferences-patch.xml" \ - --preset "$PRESET" -} - -# ── Copy config files ──────────────────────────────────────────────────────── -copy_files() { - mkdir -p \ - "$INKSCAPE_CONFIG/keys" \ - "$INKSCAPE_CONFIG/palettes" \ - "$INKSCAPE_CONFIG/templates" \ - "$INKSCAPE_CONFIG/symbols" \ - "$INKSCAPE_CONFIG/splashscreens" - - cp "$SCRIPT_DIR/config/keys/illustrator-${PRESET}.xml" \ - "$INKSCAPE_CONFIG/keys/" - - cp "$SCRIPT_DIR/config/palettes/"*.gpl "$INKSCAPE_CONFIG/palettes/" - cp "$SCRIPT_DIR/config/templates/"*.svg "$INKSCAPE_CONFIG/templates/" - cp "$SCRIPT_DIR/config/symbols/"*.svg "$INKSCAPE_CONFIG/symbols/" - - echo "→ Config files copied" -} - -# ── Install desktop launcher + icons (Linux native only) ───────────────────── -install_desktop() { - [[ "$INKSCAPE_INSTALL_METHOD" != "native" ]] && return 0 - - local app_dir="$HOME/.local/share/applications" - local icon_src="$SCRIPT_DIR/assets/illuscape.svg" - local splash_src="$SCRIPT_DIR/assets/splash.svg" - local icon_sizes=(16 32 48 64 128 256 512) - - mkdir -p "$app_dir" - cp "$SCRIPT_DIR/assets/org.inkscape.Inkscape.desktop" "$app_dir/" - - # Rasterize icon at each size (prefer rsvg-convert, fall back to Inkscape) - for size in "${icon_sizes[@]}"; do - local icon_dir="$HOME/.local/share/icons/hicolor/${size}x${size}/apps" - mkdir -p "$icon_dir" - if command -v rsvg-convert &>/dev/null; then - rsvg-convert -w "$size" -h "$size" "$icon_src" \ - -o "$icon_dir/illuscape.png" 2>/dev/null && continue - fi - if command -v inkscape &>/dev/null; then - # Inkscape 1.x uses --export-filename + --export-type (--export-png removed in 1.0) - inkscape --export-filename="$icon_dir/illuscape.png" \ - --export-type=png \ - --export-width="$size" --export-height="$size" \ - "$icon_src" 2>/dev/null && continue - fi - echo "⚠ Could not rasterize icon at ${size}px (install rsvg-convert for icons)" - done - - # Splash screen (600x400) - if command -v rsvg-convert &>/dev/null; then - rsvg-convert -w 600 -h 400 "$splash_src" \ - -o "$INKSCAPE_CONFIG/splashscreens/illuscape-splash.png" 2>/dev/null || true - fi - - update-desktop-database "$app_dir" 2>/dev/null || true - echo "→ Desktop launcher installed" -} - -# ── Main ────────────────────────────────────────────────────────────────────── -main() { - echo "╔══════════════════════════════╗" - echo "║ Illuscape Installer ║" - echo "╚══════════════════════════════╝" - echo "" - - check_deps - detect_config - backup_config - choose_preset - merge_prefs - copy_files - install_desktop - - echo "" - echo "✓ Illuscape installed (preset: $PRESET)" - echo "" - echo " Open Inkscape to start using your Illustrator-style workspace." - echo " To uninstall: ./uninstall.sh" - echo "" -} - -main +exec python3 "$SCRIPT_DIR/install.py" "$@" diff --git a/scripts/detect_platform.py b/scripts/detect_platform.py new file mode 100644 index 0000000..345ceab --- /dev/null +++ b/scripts/detect_platform.py @@ -0,0 +1,82 @@ +""" +detect_platform.py — Inkscape config-path detection, cross-platform. + +Shared by install.py and uninstall.py. +""" +from __future__ import annotations + +import os +import platform +import subprocess +from pathlib import Path + + +def _cmd_contains(cmd: list[str], pattern: str) -> bool: + """Run *cmd*; return True if stdout contains *pattern*.""" + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=5 + ) + return pattern in result.stdout + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def detect_config() -> tuple[Path, str]: + """ + Return (inkscape_config_path, install_method). + + install_method is one of: "flatpak", "snap", "native", "store" + """ + system = platform.system() + if system == "Windows": + return _detect_windows() + if system == "Darwin": + return _detect_macos() + return _detect_linux() + + +def _detect_linux() -> tuple[Path, str]: + # Flatpak takes priority — most common modern Linux install + flatpak_config = Path.home() / ".var/app/org.inkscape.Inkscape/config/inkscape" + if flatpak_config.is_dir() or _cmd_contains( + ["flatpak", "list"], "org.inkscape.Inkscape" + ): + return flatpak_config, "flatpak" + + # Snap + snap_config = Path.home() / "snap/inkscape/current/.config/inkscape" + if snap_config.is_dir() or _cmd_contains(["snap", "list"], "inkscape"): + return snap_config, "snap" + + # Native / XDG + xdg = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "inkscape" + return xdg, "native" + + +def _detect_macos() -> tuple[Path, str]: + # App Bundle (DMG install) stores config under ~/Library/Application Support + app_support = ( + Path.home() + / "Library/Application Support/org.inkscape.Inkscape/config/inkscape" + ) + if app_support.is_dir(): + return app_support, "native" + # Homebrew / XDG fallback + xdg = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "inkscape" + return xdg, "native" + + +def _detect_windows() -> tuple[Path, str]: + # MS Store — glob for the Packages directory + local_appdata = os.environ.get("LOCALAPPDATA", "") + if local_appdata: + packages = Path(local_appdata) / "Packages" + if packages.is_dir(): + matches = list(packages.glob("org.inkscape.Inkscape*")) + if matches: + return matches[0] / "LocalCache/Roaming/inkscape", "store" + + # Native (winget / Scoop / chocolatey / official installer) + appdata = Path(os.environ.get("APPDATA", Path.home() / "AppData/Roaming")) + return appdata / "inkscape", "native" diff --git a/scripts/detect_platform.sh b/scripts/detect_platform.sh index fc26622..5d2f0df 100755 --- a/scripts/detect_platform.sh +++ b/scripts/detect_platform.sh @@ -9,23 +9,23 @@ _detect_inkscape_config() { # Flatpak takes priority — most common modern Linux install local flatpak_config="$HOME/.var/app/org.inkscape.Inkscape/config/inkscape" if [[ -d "$flatpak_config" ]] || flatpak list 2>/dev/null | grep -q "org.inkscape.Inkscape"; then - INKSCAPE_CONFIG="$flatpak_config" - INKSCAPE_INSTALL_METHOD="flatpak" + export INKSCAPE_CONFIG="$flatpak_config" + export INKSCAPE_INSTALL_METHOD="flatpak" return 0 fi # Snap local snap_config="$HOME/snap/inkscape/current/.config/inkscape" if [[ -d "$snap_config" ]] || snap list 2>/dev/null | grep -q "^inkscape "; then - INKSCAPE_CONFIG="$snap_config" - INKSCAPE_INSTALL_METHOD="snap" + export INKSCAPE_CONFIG="$snap_config" + export INKSCAPE_INSTALL_METHOD="snap" return 0 fi # Native / XDG local xdg_config="${XDG_CONFIG_HOME:-$HOME/.config}/inkscape" - INKSCAPE_CONFIG="$xdg_config" - INKSCAPE_INSTALL_METHOD="native" + export INKSCAPE_CONFIG="$xdg_config" + export INKSCAPE_INSTALL_METHOD="native" return 0 } diff --git a/tests/test_install.py b/tests/test_install.py new file mode 100644 index 0000000..0f6a296 --- /dev/null +++ b/tests/test_install.py @@ -0,0 +1,222 @@ +""" +Integration tests for install.py and uninstall.py — cross-platform. +Replaces tests/test_install.bats. + +Each test runs inside a fully isolated tmp_path with a fake inkscape binary +injected via PATH and an overridden config home pointing to tmp_path. +""" +from __future__ import annotations + +import os +import platform +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO = Path(__file__).parent.parent +INSTALL_PY = REPO / "install.py" +UNINSTALL_PY = REPO / "uninstall.py" + +_FAKE_INKSCAPE_SH = """\ +#!/usr/bin/env sh +case "$1" in + --version) echo 'Inkscape 1.3.2 (1:1.3.2+202311252150+091e20ef0f)' ;; +esac +""" + +_FAKE_INKSCAPE_BAT = """\ +@echo off +if "%1"=="--version" echo Inkscape 1.3.2 (1:1.3.2+202311252150+091e20ef0f) +""" + + +def _make_fake_inkscape(bin_dir: Path) -> None: + """Create a platform-appropriate fake inkscape binary in *bin_dir*.""" + if platform.system() == "Windows": + bat = bin_dir / "inkscape.bat" + bat.write_text(_FAKE_INKSCAPE_BAT) + else: + script = bin_dir / "inkscape" + script.write_text(_FAKE_INKSCAPE_SH) + script.chmod(0o755) + + +def _config_dir(tmp_path: Path) -> Path: + """ + Return the expected config path matching detect_config() for this platform + when the env fixture overrides the config home to *tmp_path*. + """ + if platform.system() == "Windows": + # install.py on Windows: APPDATA/inkscape + return tmp_path / "config" / "inkscape" + else: + # install.py on Linux/macOS: XDG_CONFIG_HOME/inkscape + return tmp_path / "config" / "inkscape" + + +@pytest.fixture() +def env(tmp_path: Path): + """ + Isolated subprocess environment with a fake inkscape and a temp config dir. + On Linux/macOS: overrides HOME and XDG_CONFIG_HOME. + On Windows: overrides APPDATA and LOCALAPPDATA. + """ + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + _make_fake_inkscape(bin_dir) + + cfg_dir = _config_dir(tmp_path) + cfg_dir.mkdir(parents=True, exist_ok=True) + + base_env = os.environ.copy() + base_env["PATH"] = str(bin_dir) + os.pathsep + base_env.get("PATH", "") + # Block flatpak / snap detection inside subprocesses + base_env.pop("FLATPAK_ID", None) + + if platform.system() == "Windows": + base_env["APPDATA"] = str(tmp_path / "config") + base_env["LOCALAPPDATA"] = str(tmp_path / "local") + else: + base_env["XDG_CONFIG_HOME"] = str(tmp_path / "config") + base_env["HOME"] = str(tmp_path) + + return { + "tmp_path": tmp_path, + "config_dir": cfg_dir, + "env": base_env, + } + + +def _run_install(env_fixture: dict, preset: str = "cc") -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(INSTALL_PY), "--yes", f"--preset={preset}"], + env=env_fixture["env"], + cwd=str(REPO), + capture_output=True, + text=True, + ) + + +def _run_uninstall(env_fixture: dict) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(UNINSTALL_PY)], + env=env_fixture["env"], + cwd=str(REPO), + capture_output=True, + text=True, + ) + + +# ── install tests ───────────────────────────────────────────────────────────── + +def test_install_creates_backup(env): + result = _run_install(env) + assert result.returncode == 0, result.stderr + backups = list(env["tmp_path"].glob("config/inkscape.bak-illuscape-*")) + assert backups, "No backup directory created" + + +def test_install_backup_is_idempotent(env): + _run_install(env) + first_backups = sorted(env["tmp_path"].glob("config/inkscape.bak-illuscape-*")) + _run_install(env) + second_backups = sorted(env["tmp_path"].glob("config/inkscape.bak-illuscape-*")) + assert first_backups == second_backups, "Second install created an extra backup" + + +def test_install_cc_key_file(env): + result = _run_install(env, preset="cc") + assert result.returncode == 0, result.stderr + assert (env["config_dir"] / "keys/illustrator-cc.xml").exists() + + +def test_install_cs6_key_file(env): + result = _run_install(env, preset="cs6") + assert result.returncode == 0, result.stderr + assert (env["config_dir"] / "keys/illustrator-cs6.xml").exists() + + +def test_install_palettes(env): + result = _run_install(env) + assert result.returncode == 0, result.stderr + for name in ( + "Illustrator-Defaults.gpl", + "Illustrator-Grays.gpl", + "Illustrator-Earth.gpl", + ): + assert (env["config_dir"] / "palettes" / name).exists(), f"Missing palette: {name}" + + +def test_install_templates(env): + result = _run_install(env) + assert result.returncode == 0, result.stderr + for name in ( + "Letter.svg", + "A4.svg", + "Web-1920x1080.svg", + "Web-1280x720.svg", + "Print-CMYK-Letter.svg", + "Print-CMYK-A4.svg", + ): + assert (env["config_dir"] / "templates" / name).exists(), f"Missing template: {name}" + + +def test_install_creates_prefs(env): + result = _run_install(env) + assert result.returncode == 0, result.stderr + assert (env["config_dir"] / "preferences.xml").exists() + + +def test_install_prefs_contains_units(env): + result = _run_install(env) + assert result.returncode == 0, result.stderr + content = (env["config_dir"] / "preferences.xml").read_text() + assert "px" in content or "pt" in content, "No unit setting found in preferences.xml" + + +def test_install_noninteractive(env): + """--yes + --preset should exit 0 without any stdin.""" + result = _run_install(env) + assert result.returncode == 0, f"Non-interactive install failed:\n{result.stderr}" + + +# ── uninstall tests ─────────────────────────────────────────────────────────── + +def test_uninstall_restores_prefs(env): + """Original prefs.xml is restored after install + uninstall.""" + original_xml = '' + (env["config_dir"] / "preferences.xml").write_text(original_xml) + + _run_install(env) + # prefs should now contain the patched version + result = _run_uninstall(env) + assert result.returncode == 0, result.stderr + + restored = (env["config_dir"] / "preferences.xml").read_text() + assert restored == original_xml, "prefs.xml was not restored to the original content" + + +def test_uninstall_removes_palettes(env): + _run_install(env) + result = _run_uninstall(env) + assert result.returncode == 0, result.stderr + for name in ( + "Illustrator-Defaults.gpl", + "Illustrator-Grays.gpl", + "Illustrator-Earth.gpl", + ): + assert not ( + env["config_dir"] / "palettes" / name + ).exists(), f"Palette was not removed: {name}" + + +def test_uninstall_removes_templates(env): + _run_install(env) + result = _run_uninstall(env) + assert result.returncode == 0, result.stderr + for name in ("Letter.svg", "A4.svg"): + assert not ( + env["config_dir"] / "templates" / name + ).exists(), f"Template was not removed: {name}" diff --git a/uninstall.py b/uninstall.py new file mode 100644 index 0000000..e81bd8b --- /dev/null +++ b/uninstall.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +""" +Illuscape uninstaller — cross-platform (Linux, Windows, macOS). + +Usage: + python3 uninstall.py + ./uninstall.sh (Linux / macOS) +""" +from __future__ import annotations + +import os +import platform +import shutil +import subprocess +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).parent +sys.path.insert(0, str(SCRIPT_DIR / "scripts")) + +from detect_platform import detect_config # noqa: E402 + +ICON_SIZES = (16, 32, 48, 64, 128, 256, 512) + +# All files installed by install.py, keyed by subdirectory +_INSTALLED_FILES: dict[str, list[str]] = { + "keys": [ + "illustrator-cc.xml", + "illustrator-cs6.xml", + ], + "palettes": [ + "Illustrator-Defaults.gpl", + "Illustrator-Grays.gpl", + "Illustrator-Earth.gpl", + ], + "templates": [ + "Letter.svg", + "A4.svg", + "Web-1920x1080.svg", + "Web-1280x720.svg", + "Print-CMYK-Letter.svg", + "Print-CMYK-A4.svg", + ], + "symbols": [ + "illuscape-common.svg", + ], + "splashscreens": [ + "illuscape-splash.png", + ], +} + + +# ── Backup discovery ────────────────────────────────────────────────────────── + +def find_backup(config_path: Path) -> Path | None: + """Return the most recent illuscape backup directory, or None.""" + backups = sorted(config_path.parent.glob(config_path.name + ".bak-illuscape-*")) + return backups[-1] if backups else None + + +# ── Restore ─────────────────────────────────────────────────────────────────── + +def restore_prefs(backup: Path, config_path: Path) -> None: + """Copy preferences.xml back from the backup directory.""" + src = backup / "preferences.xml" + dst = config_path / "preferences.xml" + if src.exists(): + shutil.copy2(src, dst) + print(f"→ preferences.xml restored from: {backup}") + else: + print("⚠ No preferences.xml in backup — skipping restore") + + +# ── File removal ────────────────────────────────────────────────────────────── + +def remove_files(config_path: Path) -> None: + """Delete all files that install.py placed in the Inkscape config dir.""" + for subdir, names in _INSTALLED_FILES.items(): + for name in names: + (config_path / subdir / name).unlink(missing_ok=True) + (config_path / "illuscape.ico").unlink(missing_ok=True) + print("→ Illuscape config files removed") + + +# ── Desktop cleanup ─────────────────────────────────────────────────────────── + +def remove_desktop(install_method: str) -> None: + """Remove the desktop launcher / Start Menu shortcut for the current platform.""" + system = platform.system() + if system == "Linux" and install_method == "native": + _remove_linux_desktop() + elif system == "Windows": + _remove_windows_desktop() + + +def _remove_linux_desktop() -> None: + lnk = Path.home() / ".local/share/applications/org.inkscape.Inkscape.desktop" + lnk.unlink(missing_ok=True) + for size in ICON_SIZES: + icon = ( + Path.home() + / f".local/share/icons/hicolor/{size}x{size}/apps/illuscape.png" + ) + icon.unlink(missing_ok=True) + app_dir = Path.home() / ".local/share/applications" + subprocess.run(["update-desktop-database", str(app_dir)], capture_output=True) + print("→ Desktop launcher removed") + + +def _remove_windows_desktop() -> None: + lnk = ( + Path(os.environ.get("APPDATA", "")) + / "Microsoft/Windows/Start Menu/Programs/Illuscape.lnk" + ) + lnk.unlink(missing_ok=True) + print("→ Start Menu shortcut removed") + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main() -> None: + print("╔══════════════════════════════╗") + print("║ Illuscape Uninstaller ║") + print("╚══════════════════════════════╝") + print() + + config_path, install_method = detect_config() + print(f"→ Inkscape config: {config_path} ({install_method})") + + backup = find_backup(config_path) + if backup is None: + print(f"⚠ No Illuscape backup found at {config_path}.bak-illuscape-*") + print(" Cannot restore original preferences.xml automatically.") + print(" Removing only Illuscape-installed files.") + else: + restore_prefs(backup, config_path) + + remove_files(config_path) + remove_desktop(install_method) + + print() + print("✓ Illuscape uninstalled.") + print() + print(" Note: Your Inkscape preferences may have changed since Illuscape was") + print(" installed. If anything looks wrong, review:") + print(f" {config_path}/preferences.xml") + print() + + +if __name__ == "__main__": + main() diff --git a/uninstall.sh b/uninstall.sh index 6716a48..f4de503 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -1,74 +1,6 @@ #!/usr/bin/env bash -# Illuscape uninstaller +# Illuscape uninstaller — thin wrapper around uninstall.py +# Usage: ./uninstall.sh set -euo pipefail - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -source "$SCRIPT_DIR/scripts/detect_platform.sh" - -echo "╔══════════════════════════════╗" -echo "║ Illuscape Uninstaller ║" -echo "╚══════════════════════════════╝" -echo "" -echo "→ Inkscape config: $INKSCAPE_CONFIG" - -# ── Find backup ─────────────────────────────────────────────────────────────── -BACKUP=$(ls -1d "${INKSCAPE_CONFIG}".bak-illuscape-* 2>/dev/null | sort | tail -1 || true) - -if [[ -z "$BACKUP" ]]; then - echo "⚠ No Illuscape backup found at ${INKSCAPE_CONFIG}.bak-illuscape-*" - echo " Cannot restore original preferences.xml automatically." - echo " Removing only Illuscape-installed files." -else - echo "→ Restoring preferences.xml from: $BACKUP" - cp "$BACKUP/preferences.xml" "$INKSCAPE_CONFIG/preferences.xml" 2>/dev/null || true -fi - -# ── Remove Illuscape-installed files ───────────────────────────────────────── -remove_files() { - # Keys - rm -f "$INKSCAPE_CONFIG/keys/illustrator-cc.xml" - rm -f "$INKSCAPE_CONFIG/keys/illustrator-cs6.xml" - - # Palettes - rm -f "$INKSCAPE_CONFIG/palettes/Illustrator-Defaults.gpl" - rm -f "$INKSCAPE_CONFIG/palettes/Illustrator-Grays.gpl" - rm -f "$INKSCAPE_CONFIG/palettes/Illustrator-Earth.gpl" - - # Templates - rm -f "$INKSCAPE_CONFIG/templates/Letter.svg" - rm -f "$INKSCAPE_CONFIG/templates/A4.svg" - rm -f "$INKSCAPE_CONFIG/templates/Web-1920x1080.svg" - rm -f "$INKSCAPE_CONFIG/templates/Web-1280x720.svg" - rm -f "$INKSCAPE_CONFIG/templates/Print-CMYK-Letter.svg" - rm -f "$INKSCAPE_CONFIG/templates/Print-CMYK-A4.svg" - - # Symbols - rm -f "$INKSCAPE_CONFIG/symbols/illuscape-common.svg" - - # Splash - rm -f "$INKSCAPE_CONFIG/splashscreens/illuscape-splash.png" - - echo "→ Illuscape config files removed" -} - -remove_desktop() { - [[ "$INKSCAPE_INSTALL_METHOD" != "native" ]] && return 0 - rm -f "$HOME/.local/share/applications/org.inkscape.Inkscape.desktop" - for size in 16 32 48 64 128 256 512; do - rm -f "$HOME/.local/share/icons/hicolor/${size}x${size}/apps/illuscape.png" - done - update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true - echo "→ Desktop launcher removed" -} - -remove_files -remove_desktop - -echo "" -echo "✓ Illuscape uninstalled." -echo "" -echo " Note: Your Inkscape preferences may have changed since Illuscape was" -echo " installed. If anything looks wrong, review:" -echo " $INKSCAPE_CONFIG/preferences.xml" -echo "" +exec python3 "$SCRIPT_DIR/uninstall.py"