Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/ymprint/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
from rich.text import Text
from rich.console import Console
from rich.live import Live
import typer
from typer import Typer

from ..report_reader import load_report
from .config import locate_config_dir, CONFIG_FILENAMES
from .okular import ensure_okular
from ..config.config_loaders import load_config_directory

app = Typer(name='ymp', no_args_is_help=True)
Expand Down Expand Up @@ -102,6 +104,13 @@ def live(
print(file_watchers)

console = Console()

# Live mode relies on Okular to display and hot-reload the PDF. Make sure it
# is available, offering a platform-specific install if it is missing.
okular_cmd = ensure_okular(console)
if okular_cmd is None:
raise typer.Exit(code=1)

state = ThrobberState()
# watcher = FileWatcher(WATCH_FILE)
frame_time = 1.0 / FPS
Expand All @@ -112,7 +121,7 @@ def live(
f"Ctrl+C to quit[/dim]\n"
)
load_report(source, destination, config_dir)
okular_sub = subprocess.Popen(['okular', str(destination)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
okular_sub = subprocess.Popen([*okular_cmd, str(destination)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
status = ""
with Live(
build_display(state, status),
Expand Down
195 changes: 195 additions & 0 deletions src/ymprint/cli/okular.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
"""Platform-aware detection and installation of the Okular PDF viewer.

Okular is the external viewer that ``ym live`` uses to display the rendered PDF
and hot-reload it as the source changes. The helpers in this module check
whether Okular is available and, when it is not, offer to install it using the
platform-specific instructions published at https://okular.kde.org/download/.
"""
from __future__ import annotations

import platform
import shutil
import subprocess
from dataclasses import dataclass, field

import typer
from rich.console import Console

DOWNLOAD_URL = "https://okular.kde.org/download/"

# Flatpak application id used on Linux (see the download page).
FLATPAK_APP_ID = "org.kde.okular"

# Microsoft Store product id for Okular (see the download page).
WINDOWS_STORE_ID = "9N41MSQ1WNM8"


@dataclass
class Installer:
"""A single platform-specific way to install Okular.

``tool`` is the executable that must already be present for the method to be
usable, and ``command`` is the argument list run to perform the install.
"""

name: str
tool: str
command: list[str] = field(default_factory=list)


def okular_launch_command() -> list[str] | None:
"""Return the command used to launch Okular, or ``None`` if unavailable.

A native ``okular`` on ``PATH`` is preferred; a Flatpak install is used as a
fallback (it is launched through ``flatpak run`` rather than a bare binary).
"""
if shutil.which("okular"):
return ["okular"]
if _flatpak_has_okular():
return ["flatpak", "run", FLATPAK_APP_ID]
return None


def is_okular_installed() -> bool:
"""Return ``True`` if Okular can be launched on this system."""
return okular_launch_command() is not None


def _flatpak_has_okular() -> bool:
"""Return ``True`` if Okular is installed as a Flatpak."""
flatpak = shutil.which("flatpak")
if not flatpak:
return False
try:
result = subprocess.run(
[flatpak, "info", FLATPAK_APP_ID],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except OSError:
return False
return result.returncode == 0


def _linux_installers() -> list[Installer]:
"""Return installation methods available on this Linux system.

Native distribution packages are preferred (they integrate best with the
desktop); Flathub is offered as a distribution-agnostic fallback.
"""
candidates = [
Installer("apt (Debian/Ubuntu)", "apt", ["sudo", "apt", "install", "-y", "okular"]),
Installer("dnf (Fedora)", "dnf", ["sudo", "dnf", "install", "-y", "okular"]),
Installer("pacman (Arch)", "pacman", ["sudo", "pacman", "-S", "--noconfirm", "okular"]),
Installer("zypper (openSUSE)", "zypper", ["sudo", "zypper", "install", "-y", "okular"]),
Installer(
"Flatpak (Flathub)",
"flatpak",
["flatpak", "install", "-y", "flathub", FLATPAK_APP_ID],
),
]
return [i for i in candidates if shutil.which(i.tool)]


def _macos_installers() -> list[Installer]:
"""Return installation methods available on macOS."""
candidates = [
Installer("Homebrew", "brew", ["brew", "install", "--cask", "okular"]),
]
return [i for i in candidates if shutil.which(i.tool)]


def _windows_installers() -> list[Installer]:
"""Return installation methods available on Windows."""
candidates = [
Installer(
"Microsoft Store (winget)",
"winget",
[
"winget",
"install",
"--id",
WINDOWS_STORE_ID,
"--source",
"msstore",
"--accept-package-agreements",
"--accept-source-agreements",
],
),
]
return [i for i in candidates if shutil.which(i.tool)]


def available_installers() -> list[Installer]:
"""Return the installation methods usable on the current platform."""
system = platform.system()
if system == "Linux":
return _linux_installers()
if system == "Darwin":
return _macos_installers()
if system == "Windows":
return _windows_installers()
return []


def _print_manual_instructions(console: Console) -> None:
"""Point the user at the official, platform-specific install instructions."""
console.print(
"Please install Okular manually. Instructions for every platform are "
f"available at [underline]{DOWNLOAD_URL}[/underline]."
)


def run_installer(installer: Installer, console: Console) -> bool:
"""Run ``installer`` and return ``True`` if it completes successfully."""
console.print(f"[cyan]Installing Okular via {installer.name}…[/cyan]")
console.print(f" [dim]{' '.join(installer.command)}[/dim]")
try:
result = subprocess.run(installer.command)
except OSError as exc:
console.print(f"[red]Could not run the installer: {exc}[/red]")
return False
return result.returncode == 0


def ensure_okular(console: Console) -> list[str] | None:
"""Ensure Okular is installed, offering to install it if it is missing.

Returns the command used to launch Okular, or ``None`` if it is unavailable
and could not (or should not) be installed.
"""
launch = okular_launch_command()
if launch is not None:
return launch

console.print(
"[yellow]Okular does not appear to be installed on this system.[/yellow]"
)

installers = available_installers()
if not installers:
_print_manual_instructions(console)
return None

installer = installers[0]
console.print(
f"Okular can be installed with [bold]{installer.name}[/bold]."
)
if not typer.confirm("Would you like to install Okular now?", default=True):
console.print(
"[dim]Skipping installation — Okular is required for live mode.[/dim]"
)
return None

if not run_installer(installer, console):
console.print("[red]Okular installation failed.[/red]")
_print_manual_instructions(console)
return None

launch = okular_launch_command()
if launch is None:
console.print(
"[red]Okular still could not be found after installation.[/red]"
)
_print_manual_instructions(console)
return launch
Loading