Compare commits

..

3 commits

Author SHA1 Message Date
316b9bf606 fix: clear error messages when prefs or patch file missing at install time 2026-05-27 15:24:02 -07:00
7b2a2c498c docs: add development tooling disclosure 2026-05-27 14:05:16 -07:00
8e245dfe61 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
2026-05-27 11:26:16 -07:00
10 changed files with 794 additions and 267 deletions

View file

@ -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

View file

@ -124,6 +124,10 @@ Your original Inkscape config is restored from the timestamped backup created at
See [CONTRIBUTING.md](CONTRIBUTING.md). Issues and PRs welcome.
## Development tooling
All code in this repo is reviewed, tested, and owned by human contributors. LLM (large language model) coding tools are part of our development workflow.
## License
GPL-3.0 — see [LICENSE](LICENSE).

BIN
assets/illuscape.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

306
install.py Normal file
View file

@ -0,0 +1,306 @@
#!/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."""
patch_path = SCRIPT_DIR / "config/preferences-patch.xml"
if not patch_path.exists():
sys.exit(
f"✗ Installation file missing: {patch_path}\n"
" The repo may be incomplete — try re-cloning."
)
prefs_path = config_path / "preferences.xml"
if not prefs_path.exists():
print("→ No Inkscape preferences found — seeding from Illuscape defaults")
print(" (Inkscape will fill in the rest on first launch)")
try:
subprocess.run(
[
sys.executable,
str(SCRIPT_DIR / "scripts/merge_prefs.py"),
"--prefs", str(prefs_path),
"--patch", str(patch_path),
"--preset", preset,
],
check=True,
)
except subprocess.CalledProcessError:
sys.exit(
"✗ Could not merge Inkscape preferences.\n"
" Launch Inkscape once to initialise its config, then re-run this installer."
)
# ── 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()

View file

@ -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" "$@"

View file

@ -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"

View file

@ -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
}

222
tests/test_install.py Normal file
View file

@ -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 = '<inkscape version="1.0" />'
(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}"

151
uninstall.py Normal file
View file

@ -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()

View file

@ -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"