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 0000000..bf2e81d Binary files /dev/null and b/assets/illuscape.ico differ 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"