Omahub
← All plugins
W

FPL Gaffer

by weedwhitesandwine

The ultimate dashboard for Premier League fans — FPL mode for players and Fan mode for everyone else

Security review

Review recommended · 1 finding

Deterministic scan — not a security guarantee

Low
Risk level
Low
Analyzed commit
1e3b878
Scanned
1 month ago
  • low obfuscation gafferd.py:666

    Augments a command with octal/hex escape sequences.

    \x89PNG\r\n\x1a\n"

Automated analysis only — not a security guarantee.

AI advisory review

No obvious issues detected

Language-model assessment · ~deepseek/deepseek-v4-flash-latest — advisory only

Low
AI risk level
Low
Recommendation
install
Model
~deepseek/deepseek-v4-flash-latest
Analyzed commit
1e3b878
Reviewed
1 month ago

The plugin is a well-documented FPL dashboard that fetches public data over HTTPS, writes only to its own state directory, and modifies two user config files (Hyprland bindings and shell.json) only when the user explicitly changes settings, with careful validation and atomic writes. The deterministic scan's single 'obfuscation' finding is a false positive: the flagged snippet is the PNG magic-number check used to validate downloaded crest files, not obfuscated code. No credential theft, hidden persistence, or destructive behavior was found.

  • The plugin runs a long-lived Python daemon that makes network requests, but only to three known public hosts with strict redirect and size limits.
  • It can edit ~/.config/hypr/bindings.lua and ~/.config/omarchy/shell.json, but only on explicit user action and with ownership/permission checks, symlink resolution, and atomic writes.
  • The daemon reads the clipboard via wl-paste only when the user presses paste in the team-ID field, and only accepts a plain number.
How this check works

This review combines the deterministic scan (the rule-based results above) with an independent look at the plugin's code by a language model. The model reads a trimmed sample of the repository's files, the manifest, and the README, then gives a plain-language risk level and a recommendation: install (no notable danger), review (look closer first), or avoid (clearly dangerous).

It runs on the same analyzed commit as the deterministic scan and is strictly advisory — it is not a security guarantee and never blocks a plugin by itself. A human moderator still reviews plugins before they are listed.

AI advisory only — automated analysis, not a security guarantee.

Install
$ omarchy plugin add https://github.com/weedwhitesandwine/FPL-Gaffer --enable
Widgets #bar #quickshell #games

FPL Gaffer

The ultimate dashboard for Premier League fans — FPL mode for players and Fan mode for everyone else.

A Premier League and Fantasy Premier League dashboard for the Omarchy shell. Live scores, the real league table, a fixture difficulty grid, player stats and a podium board — and, if you play the game, your squad scored live with provisional bonus, projected auto-subs and your mini-leagues. All of it wearing your current Omarchy theme.

FPL Gaffer mode — your squad on the pitch

Premier League Fan mode — the live match ticker

Two ways to use it, chosen on first run and changeable in settings:

  • FPL Gaffer mode — you play the game. Eight tabs: your squad as a pitch or a list scored live, the match ticker, the league table, your mini-leagues re-scored live, a fixture difficulty grid, every player in the game, the Monsters board, and news with price and injury watch.
  • Premier League Fan mode — you just follow the football. Five tabs: Live, Table, Fixtures, Leaders and News. No fantasy team needed, and nothing that only means something inside the game.

The Live tab is headed with the gameweek it is showing and what that week is doing — nothing kicked off yet, matches in play, or all of it finished. The fantasy clock holds a gameweek as current until the next deadline, so when it does turn over, the week just watched folds away into a results drawer at the foot of the tab rather than disappearing.

Every match card opens. Underneath the scoreline, Enter or a click on the strip at its foot unfolds the team sheets drawn as a pitch — the shape the league itself published, home attacking left to right and away right to left, with shirt numbers, the captain's armband and both benches — and the match's own numbers under that: possession, shots, shots on target, corners, fouls, offsides, saves and pass accuracy, each as a bar split between the two sides. The sides appear about an hour before kick-off, when the league names them.

Club crests appear beside the scoreline and down the league table. They are downloaded once each and kept on your own disk; the screens never fetch an image while you are looking at them.

FPL Gaffer Premier League Fan
Squad, Leagues, Players ✓ —
Live, Table, Fixtures, News ✓ ✓
Line-ups, match statistics, crests ✓ ✓
Podium board Monsters — goals, defcon, value, cards, referees Leaders — goals, tackles, blocks, recoveries, cards, referees
API calls per refresh 22, or 24 when last week's results refresh 4
…plus, in both modes one more per match in play, for its statistics; the team sheets and the finished matches' numbers are held for a day, and the twenty crests are fetched once ever

Install

omarchy plugin add https://github.com/weedwhitesandwine/FPL-Gaffer.git --enable

Then restart the shell and open it once to meet the greeter, which asks which of the two modes you want and, in FPL Gaffer mode, for your team ID.

omarchy restart shell

To update or remove it later:

omarchy plugin update io.github.weedwhitesandwine.gaffer
omarchy plugin remove io.github.weedwhitesandwine.gaffer

Removing the plugin leaves your settings and cache in ~/.local/state/gaffer, so reinstalling picks up where you left off. Delete that directory yourself if you want a clean slate.

What it writes, and when

Everything this plugin runs and touches, in full — because a plugin shares the shell's process and runs with the same permissions your session already has, and you should not have to read the source to find that out.

Processes it runs

Command When
setpriv --pdeathsig TERM python3 gafferd.py daemon started by the shell when the plugin loads; the pdeathsig means it cannot outlive the shell
python3 gafferd.py once one refresh, when you open the overlay or press Ctrl+R
notify-send only to raise a notification you have switched on
wl-paste --no-newline only when you press paste in the team-ID field during setup; it reads the clipboard once and keeps the value only if it is a plain number
bash gaffer-ctl.sh bar … only when you change the bar setting in settings
bash gaffer-ctl.sh bind/unbind only when you change the hotkey in settings
hyprctl reload only from those two, after editing the hotkey block
kill <recorded pid> only from gaffer-ctl.sh stop, which you run — it kills the pid in its own lock file after checking that pid really is the engine, never a name pattern
bash -c writing one file when you change a setting or resize the window — it writes settings.json or the size file inside ~/.local/state/gaffer, staged under an exclusively-created temporary name (mktemp) and renamed into place. Values are passed as positional arguments (--, then "$1"/"$2"), never interpolated into the shell string

That table is the complete list. Every one of those commands runs as your own user, takes the values it needs as positional arguments rather than as text spliced into a shell string, and exits as soon as it has done its job — the engine on the first row is the only long-lived one.

What it needs installed

Everything in that table comes from packages an Omarchy desktop already has: python3, setpriv (util-linux), bash, hyprctl (Hyprland), notify-send (libnotify) and wl-paste (wl-clipboard). The first three are what the plugin needs to work at all — the engine is Python, and the shell starts it under setpriv. The rest are each tied to one feature: notify-send to the notifications you switch on, wl-paste to the paste button in the team-ID box, hyprctl to reloading Hyprland after a hotkey change. If one of those three is missing, that one feature is what stops working.

Files it writes

Path When
its own plugin folder never, after omarchy plugin add clones it
~/.local/state/gaffer/ — settings, state, cache, crests, log continuously, while running
~/.config/hypr/bindings.lua only if you set a hotkey, and only inside its own marked block, leaving every other line untouched
~/.config/omarchy/shell.json only if you turn the bar readout on or off. It adds, moves or removes its own entry and leaves every other setting as it found it, though the file is rewritten as standard JSON with two-space indentation. Where a dotfiles manager has symlinked this path into its own repository, the link is resolved and the real file written, so the link survives

Those last two are the only files outside its own directory it will ever touch, and neither is written unless you change that specific setting — finishing the first-run greeter does not rewrite either of them.

Everything it deletes is something it wrote itself, inside its own directory. Its cached API responses, in ~/.local/state/gaffer/cache/: on each cycle the engine drops anything older than three days, and then the oldest first while that folder is over 64 MB, so a cache cannot grow without limit on a machine left running for months. gaffer-ctl.sh clear-cache empties the same folder in one go. And in ~/.local/state/gaffer/badges/, two small cases: a file at a crest's name that turns out not to be a PNG is removed and fetched again, and the empty marker recording that a crest could not be fetched is removed once it can. Nothing outside that directory is ever removed.

Privileges. Every process in the table runs as your own user, with the permissions your session already has. setpriv is in that list for one reason — --pdeathsig TERM, which ties the engine's life to the shell's. It changes no user, grants no capability and drops none. The overlay and the bar readout both draw inside the shell's own Quickshell process.

Network: yes, and this is the point of it. Three hosts, plain HTTPS GET, unauthenticated and read-only:

  • fantasy.premierleague.com — the game's own public API, for everything the fantasy game owns: points, bonus, prices, ownership, your team, your mini-leagues, injuries and team news, and fixture difficulty.
  • footballapi.pulselive.com — the Premier League's own feed, for the football itself: the match clock, live scores, goals, bookings and the referee. The fantasy API runs two to four minutes behind the match on all of these and publishes bookings late or not at all, so where the league reports something directly, its version is used. Only matches actually in play are looked up for the clock and the score. The same feed carries the team sheets and the match statistics, which are asked for from about an hour before kick-off and held for a day once the match is over.
  • resources.premierleague.com — club crests, and nothing else. One PNG per club, twenty in total, fetched once and then read from your own disk forever. The address is built from the club's own number, which arrives as a number in the fantasy feed and is used as one, so no value from any reply can steer where this fetches from. A crest is refused above 256 KB — compressed or unpacked, so a small reply cannot inflate past it — and discarded unless the bytes that arrive actually start like a PNG. One that cannot be fetched is not asked for again for six hours, so a host that is unreachable costs one round of timeouts rather than one every cycle.

Every request is a GET, and the only value of yours that ever appears in one is your team number, which forms the URL of your own public team page. The three hosts above are the only ones contacted, and the replies are read, cached on your disk and drawn.

No feed is trusted to behave, and "only these three hosts" is enforced rather than intended. Both hosts are checked before the request and again on any redirect, so a reply that tries to send Gaffer somewhere else — off HTTPS, onto a third host, or at a service on your own machine — is refused instead of followed; a name that answers with a loopback, private or otherwise non-public address is refused for the same reason. A reply is refused above 8 MB on the wire and above 32 MB once unpacked, so neither an oversized response nor a small heavily compressed one can exhaust the memory of a process that runs all day, and a whole body has 30 seconds to arrive, so a feed that dribbles bytes to hold the connection open is dropped rather than waited on. Every one of these refusals is treated exactly like a failed request, falling back to the last good copy on disk. Every piece of text the overlay draws is pinned to plain text, so a name or a news line arriving from either API is displayed as the characters it contains and never interpreted as markup.

The same suspicion applies to the disk. Every file the engine replaces — state, cache, the bar readout, the seen-notifications list — is staged under an unpredictable name created exclusively (mkstemp, which never follows a symlink) in a directory first verified to be owned by the user and writable by nobody else, then renamed over the destination in one atomic step. Reads refuse symlinks and non-regular files, so a link or FIFO left at one of these names by a restored backup cannot redirect a write onto another file or park a read forever. The lock and log files are opened with the same no-follow guarantee before anything truncates them, and then checked to be ordinary files: refusing a symlink says nothing about a pipe, and writing to a pipe nobody is reading would hang the daemon at startup.

Stopping the engine signals only the engine. The recorded process number is not taken on trust — a lock file outlives a crash and the number in it is handed to something unrelated soon enough — so stop requires both that the process is running the engine and that it still holds the lock file open, which only the live daemon does and a recycled number cannot fake.

Timer: yes. The engine polls on an interval, because live scores are the purpose. It adapts: roughly once a minute while matches are actually being played, every few minutes in the hours before a deadline, and every fifteen minutes otherwise. Responses are cached on disk and a stale copy is used if the API is unreachable, so it is not hammering anything.

How it works

gafferd.py does all the thinking — it talks to the Fantasy Premier League API, works out live points, provisional bonus, auto-subs, league tables and fixture difficulty, and writes the result to a small state file. The overlay watches that file, so opening it is instant and nothing ever blocks on the network. The daemon also raises desktop notifications while the overlay is closed.

It polls about once a minute while matches are actually being played, every couple of minutes in the hour before a deadline, and every fifteen minutes otherwise. Everything is cached on disk, and a stale copy is used if the API is unreachable.

Standard library Python only — no virtualenv, no build step.

Keys

Key Does
Tab / ← → move between tabs
↑ ↓ / PgUp PgDn move the selection
type filter the current tab
Enter act on the selection (stars a player on the watchlist; on the Live tab, unfolds a match's line-ups and statistics)
middle-click the bar icon refresh now, without opening anything
double-click a player show him on the Players tab, selected and scrolled to
click a column heading rank by it; click again to reverse
Ctrl+, settings
Ctrl+R refresh now
Esc clear the filter, then close

Your team ID

Your FPL team ID is the number in your own team's web address. This works right now, before the season starts, with no leagues and no history:

  1. Sign in at fantasy.premierleague.com in a browser.
  2. Open Pick Team from the menu.
  3. In the "Points & Rankings" box, click Gameweek History.
  4. Look at the address bar. It reads fantasy.premierleague.com/en/entry/1234567/history — the number in the middle is your team ID.

The same number appears if you open Transfers and click Transfer History.

To check you have the right one, open fantasy.premierleague.com/api/entry/YOUR-ID/ in a browser. It should show your team name and your own name. If it shows somebody else, you have copied the wrong number.

Where things live

Path What
~/.local/state/gaffer/state.json everything the overlay draws
~/.local/state/gaffer/bar.json the small slice the bar icon reads
~/.local/state/gaffer/settings.json your choices
~/.local/state/gaffer/cache/ raw API responses
~/.local/state/gaffer/badges/ the twenty club crests, as PNGs, plus an empty marker for any that could not be fetched
~/.local/state/gaffer/gafferd.log engine log

gaffer-ctl.sh stop stops the background engine, and gaffer-ctl.sh clear-cache forgets every cached response. The engine also prunes that cache folder as it runs — entries older than three days, and the oldest first while it is over 64 MB — which is the only deleting the plugin does. The crests sit outside that folder on purpose: they are about 150 KB in total for the whole league and a club badge does not go stale, so sweeping them by age would mean fetching the same twenty files again every three days.

The bar icon

Just the ball. Your points, rank, captain, what is still to play and the time to the next deadline are all in the tooltip, so a glance at the clock is not also a score you did not ask for.

It flashes in the theme's accent colour for a couple of seconds whenever Gaffer has just told you something about the football — a goal, a red card, a kick-off, full time. It follows your notification settings: the icon flashes for the things you have asked to be told about and stays still for the rest, so turning a notification off turns its flash off too. The flash itself can be switched off in the bar's own settings for this widget, alongside where the icon sits.

Middle-clicking the icon refreshes without opening anything.

The Monsters board

A podium of three for each of ten ways to be remarkable. Most are self explanatory; three are worth a note:

  • Dirty Dogs counts bookings, not fouls. Fouls committed are not in the public API — they live in a per-match feed the game does not publish.
  • See You Next Tuesday ranks referees by cards shown. The official comes from the Premier League's own feed, the cards from the fantasy feed; a finished match is looked up once and remembered.
  • There is no set-piece category, because the API records that a player takes penalties but never which goals came from them.

Data

Everything comes from the Fantasy Premier League's own public API at fantasy.premierleague.com/api. It needs no key and no login. The match-day detail the fantasy API does not publish — the referee, the attendance, the match clock, the team sheets and the match statistics — comes from the Premier League's own feed at footballapi.pulselive.com; if that feed is unavailable the referee category goes quiet, the line-ups and statistics stay folded away, and nothing else notices. The club crests come from resources.premierleague.com, once each; without them the screens show the club names alone, which is what they showed before.

Price-change predictions are not calculated here. The game publishes its own projection per player, new for 2026/27, and the plugin reads it. It is shown as two lists: your own fifteen always, whatever their progress, and the rest of the market once a player passes 20%, sorted by how close the move is and marked with a 🔍 where you have added them to your watchlist. The 95% notification is raised for exactly two groups: the players in your own team, and the ones carrying a magnifying glass. It is not a documented or supported product, so it is treated gently: responses are cached, polling backs off when nothing is happening, and the plugin never hammers it.

Credits

Built with Claude Code.

Licence

MIT.