Skip to content
Closed
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
22 changes: 22 additions & 0 deletions fetch_cord/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from fetch_cord.config import Config
from fetch_cord.constants import (
CUSTOM_TIME_MESSAGE,
DEFAULT_CYCLE_TIME_SECONDS,
MIN_CYCLE_TIME_SECONDS,
)
from fetch_cord.cycle import Cycle
Expand All @@ -26,6 +27,7 @@
get_infos,
)
from fetch_cord.presence import ResolvedCycle, resolve_cycle
from fetch_cord.processes import PauseWatcher
from fetch_cord.resources import systemd_service
from fetch_cord.update import update

Expand Down Expand Up @@ -156,6 +158,11 @@ def main(
# handle_args has already validated this parses as a float.
cycle.time = int(float(args.time))

# CLI wins over the config file so this is usable without editing the
# packaged config.
pause_when = getattr(args, "pause_when", None) or config.get("pause_when") or []
pause = PauseWatcher(pause_when)

os_type = platform.system()
# Only the commands defined for this OS are run. The structured fastfetch
# provider fills the common fields first; command/native providers only
Expand Down Expand Up @@ -192,6 +199,21 @@ def signal_handler(signum: int, frame: object) -> None:
# Main loop
current_client_id = None
while not stop_event.is_set():
if pause.check():
# Leave the profile alone so whatever else is running keeps the
# status it would have had - and skip the snapshot entirely,
# since a rotation we are not going to send is not worth a dozen
# PowerShell processes.
for other in cycles:
if other.rpc:
other.close_connection()
current_client_id = None
if cycles:
cycles[0].wait(cycles[0].time or DEFAULT_CYCLE_TIME_SECONDS)
else:
stop_event.wait(DEFAULT_CYCLE_TIME_SECONDS)
continue

# Collect every field once per rotation rather than once per cycle.
# Each cycle reads different fields out of the same snapshot, and on
# Windows a snapshot is a dozen PowerShell processes.
Expand Down
10 changes: 10 additions & 0 deletions fetch_cord/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@ def parse_args() -> argparse.Namespace:
action="store_true",
help="Say whether FetchCord is set to start when you sign in.",
)
parser.add_argument(
"--pause-when",
metavar="NAME",
nargs="+",
help=(
"Process names that should hold the presence back while they run "
"(e.g. --pause-when steam spotify). Overrides pause_when in the "
"config. The .exe suffix is optional."
),
)
parser.add_argument(
"--dry-run",
action="store_true",
Expand Down
90 changes: 90 additions & 0 deletions fetch_cord/processes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Noticing when another program should own the Rich Presence instead.

Discord shows one activity at a time, so while a game or Spotify is running
FetchCord's system info replaces the status the user actually wants to see.
``pause_when`` lists the programs that should win.
"""

from collections.abc import Iterable

import psutil


def _variants(name: str) -> set[str]:
"""A process name with and without its ``.exe`` suffix.

Lets one config line ("steam") match on every platform.
"""
name = name.strip().lower()
if not name:
return set()

if name.endswith(".exe"):
return {name, name[: -len(".exe")]}
return {name, name + ".exe"}


def running_names() -> list[str]:
"""Lowercased names of every process we can see."""
names: list[str] = []

try:
processes = psutil.process_iter(["name"])
except psutil.Error:
return []

for process in processes:
try:
name = process.info.get("name")
except (psutil.Error, KeyError):
# A process exiting between listing and reading is normal and
# must not abandon the whole scan.
continue
if name:
names.append(name.lower())

return names


def find_running(names: Iterable[str]) -> str | None:
"""The first of ``names`` currently running, as the caller spelled it."""
wanted: dict[str, str] = {}
for name in names:
for variant in _variants(name):
wanted[variant] = name.strip()

if not wanted:
return None

for running in running_names():
if running in wanted:
return wanted[running]

return None


class PauseWatcher:
"""Tracks which configured program is holding the presence back.

Announces the transitions only. This is checked once per cycle, so a line
every time would be noise.
"""

def __init__(self, names: Iterable[str] | None = None) -> None:
self.names = [name for name in (names or []) if name and name.strip()]
self.paused_for: str | None = None

def check(self) -> str | None:
"""The program currently pausing us, or None."""
if not self.names:
return None

found = find_running(self.names)

if found and found != self.paused_for:
print(f"Pausing: {found} is running.")
elif self.paused_for and not found:
print(f"{self.paused_for} closed, resuming.")

self.paused_for = found
return found
9 changes: 9 additions & 0 deletions fetch_cord/resources/fetchcord_conf.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
---
# Programs that should keep their own Discord status while they run.
# FetchCord clears its presence until they exit. The .exe suffix is
# optional, so one entry works on every platform.
#
# pause_when:
# - steam
# - spotify
pause_when: []

cycle_time: &cycle_time 30
cycles:
- name: os
Expand Down
109 changes: 109 additions & 0 deletions tests/test_more.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,8 @@ def _ns(self, **kwargs: object) -> argparse.Namespace:
"version": False,
"time": None,
"debug": False,
"dry_run": False,
"pause_when": None,
"nodistro": False,
"nohardware": False,
"noshell": False,
Expand Down Expand Up @@ -473,6 +475,112 @@ def record_update(*args: object, **kwargs: object) -> None:
self.assertEqual(fake_fetch.snapshot.call_count, 1)


class TestPauseWhenInTheLoop(unittest.TestCase):
def _ns(self, **kwargs: object) -> argparse.Namespace:
defaults: dict[str, object] = {
"update": False,
"testing": False,
"install": False,
"uninstall": False,
"enable": False,
"disable": False,
"start": False,
"stop": False,
"status": False,
"version": False,
"time": None,
"debug": False,
"pause_when": ["steam"],
"dry_run": False,
"nodistro": False,
"nohardware": False,
"noshell": False,
"nohost": False,
}
defaults.update(kwargs)
return argparse.Namespace(**defaults)

@patch("fetch_cord.processes.running_names", return_value=["steam.exe"])
@patch("fetch_cord.__main__.Fetch")
@patch("fetch_cord.__main__.get_infos", return_value={})
@patch("fetch_cord.cycle.Cycle.close_connection")
@patch("fetch_cord.cycle.Cycle.update")
@patch("fetch_cord.cycle.Cycle.setup")
def test_a_paused_program_stops_us_updating(
self,
mock_setup: MagicMock,
mock_update: MagicMock,
mock_close: MagicMock,
mock_get_infos: MagicMock,
mock_fetch_class: MagicMock,
mock_names: MagicMock,
) -> None:
"""The whole point: leave the profile to whatever else is running."""
from threading import Event

from fetch_cord.__main__ import main

fake_fetch = MagicMock()
fake_fetch.snapshot.return_value = {}
mock_fetch_class.return_value = fake_fetch

stop = Event()
waits: list[object] = []

def record_wait(*args: object, **kwargs: object) -> None:
waits.append(args)
if len(waits) >= 4:
stop.set()

with patch("fetch_cord.cycle.Cycle.wait", side_effect=record_wait):
main(self._ns(), stop_event=stop)

mock_update.assert_not_called()
mock_setup.assert_not_called()
# Nothing to send means nothing to collect: a snapshot is a dozen
# PowerShell processes on Windows, and pausing is meant to get out
# of the way, not just stay quiet.
fake_fetch.snapshot.assert_not_called()
# It still paces itself rather than spinning.
self.assertGreaterEqual(len(waits), 4)

@patch("fetch_cord.processes.running_names", return_value=["firefox"])
@patch("fetch_cord.__main__.Fetch")
@patch("fetch_cord.presence.get_component_id", return_value="client-1")
@patch("fetch_cord.__main__.get_infos", return_value={})
@patch("fetch_cord.cycle.Cycle.try_connect")
@patch("fetch_cord.cycle.Cycle.setup")
def test_nothing_matching_leaves_the_loop_alone(
self,
mock_setup: MagicMock,
mock_try_connect: MagicMock,
mock_get_infos: MagicMock,
mock_get_component_id: MagicMock,
mock_fetch_class: MagicMock,
mock_names: MagicMock,
) -> None:
from threading import Event

from fetch_cord.__main__ import main

fake_fetch = MagicMock()
fake_fetch.snapshot.return_value = {}
mock_fetch_class.return_value = fake_fetch

stop = Event()
updates: list[object] = []

def record_update(*args: object, **kwargs: object) -> None:
updates.append(args)
if len(updates) >= 2:
stop.set()

with patch("fetch_cord.cycle.Cycle.update", side_effect=record_update):
main(self._ns(), stop_event=stop)

self.assertGreaterEqual(len(updates), 2)


class TestDryRun(unittest.TestCase):
def _ns(self, **kwargs: object) -> argparse.Namespace:
defaults: dict[str, object] = {
Expand All @@ -489,6 +597,7 @@ def _ns(self, **kwargs: object) -> argparse.Namespace:
"time": None,
"debug": False,
"dry_run": True,
"pause_when": None,
"nodistro": False,
"nohardware": False,
"noshell": False,
Expand Down
Loading