blog.ganska.latRSS

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