Bygg en bilduppladdare för Wayland-urklipp med Python och Nix
Vad vi bygger
$ qup t.png
https://quad.pe/e/8fTsXO5uVY.png
$ wl-paste -n
https://quad.pe/e/8fTsXO5uVY.png
$ qup t.txt
qup: t: not a recognised image
$ echo $?
2
Ett kommando som tar en bild från kommandoraden, från en pipe eller direkt från Wayland-urklippet, laddar upp den, skriver adressen till stdout, lägger den i urklippet och sparar den i en historik du kan söka i med fzf. Det ersätter turen att öppna en webbläsare, dra in filen i ett uppladdningsfält och kopiera adressen för hand.
Uppladdningstjänsten i exemplet är quad.pe, som tar emot anonyma bilder utan konto. Metoden i artikeln gäller vilken uppladdningstjänst som helst: nästan inget här handlar om just den tjänsten, och det som gör det är isolerat till tre konstanter.
Varför det fungerar så här
Tjänsten publicerar ingen API-dokumentation. Kontraktet gick att läsa ur
webbappens JavaScript-bundle, och där syns också varför valideringen måste ligga
lokalt: skickar du något som inte är en bild svarar servern HTTP 500 med
{"errors":[{"title":"storing image"}]}. Det är allt. Alternativet som förlorade
var att låta servern validera och bara vidarebefordra dess fel, för då hade
användaren fått se “storing image” när det egentliga problemet var att filen var
en textfil. Verktyget läser därför de första byten själv och avgör formatet innan
det rör nätverket alls.
Det andra vägvalet handlar om PATH. Verktyget startar wl-paste, wl-copy,
fzf och notify-send som underprocesser, och det naturliga är att lita på att
de finns i PATH. Det håller i ett skal och går sönder på ett kortkommando: en
tangentbindning i niri startar processen med en minimal miljö, så wl-paste
finns inte där. Buggen syns bara i det ena fallet, vilket gör den obehaglig att
felsöka. Nix-paketet lägger därför in verktygen i wrapperns egen PATH, så
programmet aldrig behöver ärva dem.
Vad du behöver
| Sak | Version | Varifrån |
|---|---|---|
| Python | 3.14.6 | nixpkgs-pinnen i flake.lock |
| requests | 2.34.2 | python3Packages.requests i samma pin |
| wl-clipboard | 2.3.0 | pkgs.wl-clipboard, ger wl-paste och wl-copy |
| fzf | 0.74.2 | pkgs.fzf, historikväljaren |
| libnotify | 0.8.8 | pkgs.libnotify, ger notify-send |
| nixfmt | 1.4.0 | pkgs.nixfmt, formateraren |
Python-kravet i paketmetadatan är lägre än pinnen:
requires-python = ">=3.11"
Hur delarna hänger ihop
Alla tre indatakällor mynnar ut i samma väg. Bara historikväljaren går vid sidan om, eftersom den inte laddar upp något.
flowchart TD
A["argv: qup shot.png"] --> D["collect()"]
B["pipe: grim - | qup"] --> D
C["urklipp: qup -c"] --> D
D --> E["sniff()"]
E -->|"None"| F["exit 2, ingen nätverkstrafik"]
E -->|"png, jpeg, ..."| G["upload()"]
G --> H["POST /api/upload"]
H --> I["url"]
I --> J["stdout"]
I --> K["clip_write()"]
I --> L["remember()"]
M["qup -H"] --> N["load_history()"]
N --> O["fzf"]
O --> K
Följ en körning av qup -c. Argumentparsern ser flaggan och collect() anropar
clip_read(), som frågar wl-paste vilka typer urklippet erbjuder och hämtar
den första som börjar med image/. Byten går till sniff(), som matchar
signaturen och svarar ("png", "image/png"). Filändelsen används för att bygga
filnamnet i multipart-anropet, upload() gör POST-anropet och returnerar
adressen, remember() lägger till en rad i historiken och clip_write() lägger
adressen i urklippet. Det som låg där, bilden, är nu ersatt av adressen till
samma bild.
Steg 1: Läs API-kontraktet ur webbappen
Målet är ett verifierat kontrakt: metod, fältnamn och svarsformat, utan att gissa.
Startsidan är ett skal som laddar en bundle med innehållshashat filnamn, så den måste hämtas först:
$ curl -s https://quad.pe/ | grep -o '/assets/web-[^"]*\.js'
/assets/web-DdrkMe6O.js
Uppladdningsfunktionen ligger i klartext i bundlen:
$ curl -s https://quad.pe/assets/web-DdrkMe6O.js > web.js
$ grep -oE '.{60}FormData.{200}' web.js
let n=new FormData;
n.append(`image`,e.file,e.file.name),n.append(`ctx`,e.ctx),n.append(`return_json`,`true`);
let r=new XMLHttpRequest;r.responseType=`json`,r.open(`POST`,`/api/upload`)
Tre fält, en endpoint. Bekräfta mot den riktiga tjänsten innan du skriver kod:
$ curl -sS -F "image=@t.png" -F "ctx=cli" -F "return_json=true" \
https://quad.pe/api/upload
{"data":{"id":"e/n01gV53j2f.png","type":"image"}}
Adressen är https://quad.pe/ plus id. Prova sedan det som ska gå fel:
$ echo hej > t.txt
$ curl -sS -F "image=@t.txt" -F "ctx=cli" -F "return_json=true" \
https://quad.pe/api/upload -w "\nHTTP %{http_code}\n"
{"errors":[{"title":"storing image"}]}
HTTP 500
Det svaret är hela motiveringen för nästa steg. Notera att bundlens filnamn ändras vid varje ny version av webbappen, så hämta startsidan igen om uppladdningarna slutar fungera.
Steg 2: Känn igen bilden lokalt
Målet är att en textfil aldrig når nätverket, och att filändelsen i multipart-anropet stämmer med innehållet.
# quad.pe answers a non-image with a bare 500, so the check happens here instead.
# The sniffed extension also drives the multipart filename, since it is unclear
# whether the server sniffs content or trusts the name.
def sniff(data):
if data[:8] == b"\x89PNG\r\n\x1a\n":
return "png", "image/png"
if data[:3] == b"\xff\xd8\xff":
return "jpg", "image/jpeg"
if data[:6] in (b"GIF87a", b"GIF89a"):
return "gif", "image/gif"
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "webp", "image/webp"
if data[:2] == b"BM":
return "bmp", "image/bmp"
if data[4:8] == b"ftyp":
brand = data[8:12]
if brand in (b"avif", b"avis"):
return "avif", "image/avif"
if brand in (b"heic", b"heix", b"heim", b"heis", b"mif1"):
return "heic", "image/heic"
return None
Två rader är inte vad de ser ut att vara. WebP-kontrollen räcker inte med RIFF,
eftersom RIFF också är containern för AVI och WAV, så typen står på position 8.
AVIF och HEIC delar ISO-BMFF-huvud där ftyp ligger på position 4 och märket på
position 8, vilket är varför de två sitter i samma gren i stället för att ha egna
prefixtester.
Att slicea utanför en kort bytesträng är ofarligt i Python, så en tom fil faller
igenom till return None utan specialfall.
Kontroll:
$ python3 -c "import qup; print(qup.sniff(open('t.png','rb').read()))"
('png', 'image/png')
$ python3 -c "import qup; print(qup.sniff(b'hej'))"
None
Steg 3: Ladda upp
Målet är en funktion som antingen ger en adress eller ett fel som går att läsa.
def upload(data, name):
try:
r = requests.post(
API,
files={"image": (name, data)},
data={"ctx": CTX, "return_json": "true"},
timeout=TIMEOUT,
)
except requests.RequestException as e:
raise UploadError(str(e)) from e
if r.status_code != 200:
raise UploadError(f"HTTP {r.status_code}: {error_title(r)}")
return BASE + r.json()["data"]["id"]
files och data i samma anrop är det som gör requests-anropet till en
multipart-kropp med alla tre fälten, i stället för en formulärkropp med en
bilaga. Namnet i files är det som styr filändelsen tjänsten ger tillbaka, och
det kommer från sniff(), inte från filnamnet på disk.
Felen slås ihop till en typ, så anroparen har ett except i stället för tre:
def error_title(response):
try:
return response.json()["errors"][0]["title"]
except (ValueError, KeyError, IndexError):
return response.text[:200]
Kontroll:
$ python3 -c "import qup; print(qup.upload(open('t.png','rb').read(), 't.png'))"
https://quad.pe/e/Iqb6ZhQB9X.png
Steg 4: Tre indatakällor, en väg
Målet är att läget avgörs av hur kommandot anropas, utan underkommandon.
def collect(args):
if args.clip:
data = clip_read()
if data is None:
print("qup: no image in clipboard", file=sys.stderr)
sys.exit(E_NO_CLIP)
return [(data, "clipboard", "clip")]
if args.files:
return [(p.read_bytes(), p.stem, "file") for p in args.files]
return [(sys.stdin.buffer.read(), "stdin", "stdin")]
Sista raden läser stdin utan att fråga, vilket bara är rätt för att anroparen redan har kontrollerat att stdin inte är en terminal:
if args.history:
return pick()
if args.clip and args.files:
ap.error("--clip takes no file arguments")
if not args.clip and not args.files and sys.stdin.isatty():
ap.print_help()
return 0
Utan isatty()-raden skulle ett anrop utan argument blockera på en tom
terminal, vilket ser ut som att programmet har hängt sig. Nu skriver det hjälpen
i stället.
Kontroll:
$ qup < t.png
https://quad.pe/e/Qw5qUsGcKo.png
$ qup
usage: qup [-h] [-c] [-H] [files ...]
Steg 5: Läs bilden från Wayland-urklippet
Målet är att hämta en bild oavsett vilken bildtyp som råkar ligga i urklippet.
def clip_read():
listing = subprocess.run(
["wl-paste", "--list-types"], capture_output=True, text=True, check=False
)
for mime in listing.stdout.split():
if mime.startswith("image/"):
out = subprocess.run(
["wl-paste", "--type", mime, "--no-newline"],
capture_output=True,
check=False,
)
if out.returncode == 0 and out.stdout:
return out.stdout
return None
Frågan om typerna kommer först av en anledning. Hårdkodar du
wl-paste --type image/png fungerar det för skärmbilder och misslyckas tyst för
en JPEG kopierad ur en webbläsare. --no-newline är inte kosmetik heller: utan
den lägger wl-paste till en radbrytning i slutet av binärdatan, vilket ger en
trasig fil.
Skrivningen tillbaka går via stdin, inte som argument:
def clip_write(text):
subprocess.run(
["wl-copy"], input=text.encode(), stderr=subprocess.DEVNULL, check=False
)
wl-copy tolkar sina positionsargument som text att kopiera, så en adress som
börjar med bindestreck hade kunnat läsas som en flagga. Via stdin finns frågan
inte.
Kontroll:
$ wl-copy --type image/png < t.png && qup -c
https://quad.pe/e/m3fJ5NMmnm.png
$ wl-paste -n
https://quad.pe/e/m3fJ5NMmnm.png
Steg 6: Historik som JSONL, sökbar med fzf
Målet är en historik som tål en krasch mitt i en skrivning.
def remember(url, name, source, size):
path = history_path()
path.parent.mkdir(parents=True, exist_ok=True)
record = {
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"url": url,
"name": name,
"source": source,
"bytes": size,
}
with path.open("a") as f:
f.write(json.dumps(record) + "\n")
En rad per uppladdning, alltid tillagd sist. En avbruten skrivning kostar en rad, inte filen, vilket är hela poängen med formatet och också varför läsningen hoppar över trasiga rader i stället för att ge upp:
def load_history(path):
if not path.exists():
return []
records = []
for line in path.read_text().splitlines():
try:
records.append(json.loads(line))
except json.JSONDecodeError:
continue # a crash mid-append can leave one partial line
return records
Väljaren vänder på listan och lämnar över till fzf:
def pick():
records = load_history(history_path())
if not records:
print("qup: no upload history yet", file=sys.stderr)
return E_NO_HISTORY
lines = []
for r in reversed(records):
display = f"{r['ts'][:19]} {r['source']:<5} {r['name']} {r['url']}"
lines.append(f"{r['url']}\t{display}")
res = subprocess.run(
["fzf", "--delimiter=\t", "--with-nth=2..", "--prompt=qup> "],
input="\n".join(lines),
capture_output=True,
text=True,
check=False,
)
if res.returncode != 0:
return 0 # cancelled
url = res.stdout.strip().split("\t")[0]
clip_write(url)
print(url)
return 0
Kombinationen --delimiter och --with-nth=2.. är tricket. Adressen läggs
först på raden och göms sedan från visningen, så fzf söker och visar den läsbara
kolumnen medan programmet får tillbaka en rad det kan plocka adressen ur med en
enkel split. Alternativet, att parsa adressen ur den formaterade texten, går
sönder så fort ett filnamn innehåller ett mellanslag.
returncode != 0 betyder att du tryckte Escape, vilket inte är ett fel. Därför
0 och inte en felkod.
Kontroll:
$ qup -H
2026-08-11T23:17:32 clip clipboard.png https://quad.pe/e/m3fJ5NMmnm.png
2026-08-11T23:17:21 stdin stdin.png https://quad.pe/e/Qw5qUsGcKo.png
2026-08-11T23:17:21 file t.png https://quad.pe/e/Iqb6ZhQB9X.png
Steg 7: Paketera som flake, och baka in PATH
Målet är ett kommando som fungerar likadant från ett skal och från ett kortkommando.
qup = pkgs.python3Packages.buildPythonApplication {
pname = "qup";
version = "0.1.0";
pyproject = true;
src = ./.;
build-system = [ pkgs.python3Packages.setuptools ];
dependencies = [ pkgs.python3Packages.requests ];
nativeCheckInputs = [ pkgs.python3Packages.pytestCheckHook ];
# A niri keybind spawns with a minimal environment, so the helpers qup
# shells out to are baked into its PATH rather than inherited.
makeWrapperArgs = [
"--prefix PATH : ${
pkgs.lib.makeBinPath [
pkgs.wl-clipboard
pkgs.fzf
pkgs.libnotify
]
}"
];
makeWrapperArgs är raden som gör kortkommandot pålitligt. Allt som startas som
underprocess måste stå i listan, annars uppstår en bugg som bara syns när
programmet körs från tangentbindningen och aldrig när du testar i ett skal.
pyproject = true med build-system och dependencies är det nuvarande
gränssnittet i nixpkgs. Äldre exempel använder format = "pyproject" och
propagatedBuildInputs, som fortfarande fungerar men är på väg bort.
Kontroll, med en helt tom miljö, vilket är närmare vad tangentbindningen ger än vad ditt skal ger:
$ env -i result/bin/qup --help
usage: qup [-h] [-c] [-H] [files ...]
Steg 8: Få nix flake check att faktiskt köra testerna
Målet är att kontrollkommandot säger nej när testerna går sönder.
Utan ett checks-utdata utvärderar nix flake check bara paketet. Det bygger
det aldrig, så testsviten i checkPhase körs inte och kontrollen går igenom utan
att ha kontrollerat något:
# Without this, `nix flake check` only evaluates the package and never builds
# it, so the pytest suite in its check phase never runs.
checks = forAllSystems (pkgs: {
qup = self.packages.${pkgs.stdenv.hostPlatform.system}.qup;
});
Formateraren behöver också ett omslag, eftersom nix fmt skickar en katalog och
nixfmt bara tar filer:
# `nix fmt` hands the formatter a directory, but nixfmt only takes files.
formatter = forAllSystems (
pkgs:
pkgs.writeShellApplication {
name = "fmt";
runtimeInputs = [ pkgs.nixfmt ];
text = ''find "''${1:-.}" -name '*.nix' -exec nixfmt {} +'';
}
);
Kontroll. Raden running 1 flake checks är beviset, för den saknades helt förut:
$ nix flake check
running 1 flake checks...
building '/nix/store/qqka1p0hh5a0zbvl2qyx1dkiihs89cd6-qup-0.1.0.drv'...
all checks passed!
$ nix build .#checks.x86_64-linux.qup --rebuild -L
qup> ============================= test session starts ==============================
qup> ============================== 19 passed in 0.08s ==============================
Filformat
Historikfilen, en rad per uppladdning, i $XDG_DATA_HOME/qup/history.jsonl:
{"ts":"2026-08-11T23:17:32+0200","url":"https://quad.pe/e/m3fJ5NMmnm.png","name":"clipboard.png","source":"clip","bytes":67}
| Fält | Betydelse |
|---|---|
ts |
lokal tid med offset, från time.strftime("%Y-%m-%dT%H:%M:%S%z") |
url |
den fullständiga adressen, redan sammansatt |
name |
filnamnet som skickades, med filändelsen från sniff() |
source |
clip, file eller stdin, alltså vilket läge som användes |
bytes |
storleken på det uppladdade innehållet |
Svaret från uppladdningen:
{"data":{"id":"e/n01gV53j2f.png","type":"image"}}
id innehåller redan filändelsen och ett katalogsteg. Adressen är id tillagd
efter tjänstens bas-URL, inget mer.
Vad som bet oss
Kontrollen som gick igenom utan att kontrollera. nix flake check
utvärderade paketet och byggde det aldrig, så testerna kördes inte en enda gång
medan de ändå rapporterades som gröna. Skillnaden syns i utdatan: utan
checks-utdata saknas raden running 1 flake checks helt, och det är lätt att
läsa förbi. Ett checks-utdata som pekar på paketet självt är hela åtgärden.
nix fmt som dog på sitt eget argument. Felet var
unexpected end of input, expecting expression, vilket ser ut som ett trasigt
Nix-uttryck men var något annat: nix fmt skickar en katalog till formateraren
och nixfmt tar bara filer, så den försökte parsa katalogen. Ett find-omslag
löser det. Samtidigt är nixfmt-rfc-style numera ett alias som varnar, så peka
på pkgs.nixfmt direkt.
Stderr som läckte in i pipen. wl-copy och notify-send misslyckas utanför
en Wayland-session, till exempel över ssh eller från ett cron-jobb. Adressen nådde
fortfarande stdout och avslutskoden var fortfarande 0, men klagomålen hamnade i
terminalen mitt i en pipeline. Båda är hjälpsteg vars misslyckande inte ska
angå anroparen, så deras stderr går till /dev/null.
--override-input med en naken sökväg tar inte din arbetskopia. Det här är
det otäckaste, för det misslyckas tyst. Pekar du en flake-input på en katalog som
är ett git-arkiv löser Nix upp den till git+file://, vilket ger senaste
commit, inte det som ligger på disk. Med prefixet path: får du arbetskopian.
De ger olika sökvägar i store:
$ nix build .#default --override-input qup "path:/sökväg/till/projektet"
-> /nix/store/hhp16ynqqmycglj65wkwnnv9rq59nxw2-qup-0.1.0 (arbetskopian)
$ nix build .#default --override-input qup "/sökväg/till/projektet"
-> /nix/store/kjgfv6yzdg36chi4lr9v5ajwczfvxrk7-qup-0.1.0 (senaste commit)
En ocommittad rättning ser alltså ut att inte ha någon effekt. Behåll path:.
Modulargument ärvs inte automatiskt. I en NixOS-uppsättning når inputs
home-manager-moduler via extraSpecialArgs, men varje modul måste ändå deklarera
det i sin argumentlista. En modul som börjar med { pkgs, ... }: måste bli
{ pkgs, inputs, ... }: innan den kan referera till en flake-input, annars
faller utvärderingen på undefined variable 'inputs'.
Referenser
- wl-clipboard,
wl-copyochwl-paste - fzf, inklusive
--with-nthoch--delimiter - niri, fönsterhanteraren vars tangentbindningar startar kommandot
- nixpkgs manual, Python, för
buildPythonApplicationochpytestCheckHook - nix flake, referenssyntax, för skillnaden mellan
path:ochgit+file: - requests, för
filesochdatai samma anrop