Files
voculinux/src/vocalinux/single_instance.py
T
Jatin K MalikandGitHub 3deb523efd feat(flatpak): add Flatpak packaging for universal distribution (#484)
* feat(flatpak): add Flatpak packaging for universal distribution

Adds a Flathub-ready Flatpak for the whisper.cpp engine, plus the minimal
runtime adaptations Flatpak actually needs — deliberately scoped down from the
earlier attempts (#416, #428), which bundled unrelated whisper.cpp GPU, audio,
and clipboard changes.

Packaging (packaging/flatpak/):
- app manifest, AppStream metainfo, and desktop entry (com.vocalinux.Vocalinux)
- generated offline Python dependency modules (flatpak-pip-generator)
- bundled shared modules: PortAudio, libayatana-appindicator, xdotool, xsel

Runtime changes (Flatpak-only, behaviour unchanged outside a sandbox):
- utils/paths.py: honour XDG_CONFIG_HOME/XDG_DATA_HOME so config, models and
  runtime data live under ~/.var/app/<app-id> in the sandbox
- autostart uses `flatpak run <app-id>` (the host launcher is always on PATH)
- text injection and keyboard backend detect a Wayland host reached via XWayland
  (no Wayland socket granted) and route through xdotool / pynput-on-X11
- tray indicator references the exported `<app-id>-*` host icon theme names

Also: a CI workflow building x86_64 + aarch64 bundles, and README / packaging
README / distro-compatibility doc updates.

Verified with a full flatpak-builder build + bundle install on aarch64:
pywhispercpp loads its native libraries without LD_LIBRARY_PATH, XDG paths
redirect correctly, bundled tools are present, and the entrypoint runs.
Manifest and builddir lint are clean and AppStream metadata validates.

Closes #167

* chore(flatpak): sync metainfo version to 0.14.0-beta

Also ignore flatpak-build-dir/ so local builder output stays untracked.

* refactor(flatpak): trim docs and comments after ponytail review

Drop redundant packaging README command blocks, shrink path/tray/autostart
comments, and stop ignoring a non-canonical build-dir name.

* fix(flatpak): close readiness gaps for v0.14.0-beta

Route log files through XDG data dirs so Flatpak keeps them under
~/.var/app, refresh AppStream screenshots to vocalinux.com, and use
the SPDX GPL-3.0-only license id.

* fix(flatpak): enable global hotkeys via evdev and --device=input

pynput over XWayland only receives keys for the focused X client. Prefer
evdev inside Flatpak and grant /dev/input so shortcuts work app-wide.

* fix(flatpak): inject text with ydotool/uinput on Wayland

xdotool only reaches XWayland clients, so typing appeared to succeed but
never hit native apps. Ship ydotool, grant --device=all for uinput, start
ydotoold on demand, and type via uinput inside the Flatpak.

* fix(flatpak): paste via wl-copy + Ctrl+V instead of typing

ydotool type injects character-by-character. Ship wl-clipboard, grant
--socket=wayland, and prefer clipboard paste for instant insertion.

* test(text-injection): expect ydotool default key-delay of 2

Matches the shipped VOCALINUX_YDOTOOL_KEY_DELAY default used when
clipboard paste falls back to character typing.
2026-07-16 00:03:30 -07:00

101 lines
2.9 KiB
Python

"""
Single instance enforcement for Vocalinux.
Uses file locking to ensure only one instance runs at a time.
Works across sessions and handles stale locks from crashed processes.
"""
import fcntl
import logging
import os
import sys
from pathlib import Path
from typing import Optional
from .utils.paths import data_dir
logger = logging.getLogger(__name__)
# Lock file location: $XDG_DATA_HOME/vocalinux/ (defaults to ~/.local/share/vocalinux/)
LOCK_FILE_DIR = Path(data_dir())
LOCK_FILE_PATH = LOCK_FILE_DIR / "instance.lock"
# Global lock file handle
_lock_file: Optional[int] = None
def _get_lock_file_fd() -> int:
"""Get or create the lock file descriptor."""
global _lock_file
if _lock_file is not None:
return _lock_file
# Ensure directory exists
LOCK_FILE_DIR.mkdir(parents=True, exist_ok=True)
# Open lock file (create if doesn't exist)
# O_RDWR: read/write, O_CREAT: create if not exists
fd = os.open(str(LOCK_FILE_PATH), os.O_RDWR | os.O_CREAT, 0o644)
_lock_file = fd
return fd
def acquire_lock() -> bool:
"""
Acquire the single instance lock.
Returns:
True if lock acquired (first instance), False if another instance is running.
If another instance is running, logs a message and returns False.
"""
try:
fd = _get_lock_file_fd()
# Try to acquire exclusive lock (non-blocking)
# LOCK_EX: exclusive lock
# LOCK_NB: non-blocking (fail immediately if lock held by another process)
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except (OSError, IOError):
# Lock is held by another process
logger.warning("Another instance of Vocalinux is already running. Exiting.")
print("Another instance of Vocalinux is already running.", file=sys.stderr)
print(
f"If you're sure no other instance is running, delete: {LOCK_FILE_PATH}",
file=sys.stderr,
)
return False
# Lock acquired - write our PID for debugging purposes
os.ftruncate(fd, 0)
os.write(fd, f"{os.getpid()}\n".encode())
os.lseek(fd, 0, os.SEEK_SET)
logger.info("Single instance lock acquired")
return True
except Exception as e:
# Unexpected error - log and allow startup (fail open but warn)
logger.error(f"Error acquiring single instance lock: {e}")
return True
def release_lock():
"""Release the single instance lock."""
global _lock_file
if _lock_file is not None:
try:
# Release lock
fcntl.flock(_lock_file, fcntl.LOCK_UN)
os.close(_lock_file)
_lock_file = None
logger.debug("Single instance lock released")
except (OSError, IOError) as e:
logger.warning(f"Failed to release lock: {e}")
# Ensure clean state
_lock_file = None