Slack screenshare chooser on sway

Recently, screensharing in Slack huddles got annoying. The usual Slack share dialog still appeared, but additionally something popped up in the sway bar, stole the keyboard focus and I had to press Escape (twice) before I could continue. Sharing the full screen still worked, but the popup was blocking everything until dismissed.

Two different choosers are involved here. The share dialog with the "Entire screen" / "Window" tabs is Slack's own (Electron) picker and was always there. On Wayland, Electron cannot capture the screen itself: it requests a PipeWire stream via xdg-desktop-portal, and the portal backend decides which output backs that stream -- even the live preview inside Slack's dialog comes from such a stream.

The new thing in the bar turned out to be bemenu, started by xdg-desktop-portal-wlr as a source chooser. When an application requests a screencast, the portal walks through a list of chooser commands (wmenu, wofi, rofi, bemenu, ...) and runs the first one that exists. On my system only bemenu is installed, so that one won. The journal shows the chain:

/bin/sh: line 1: rofi: command not found
/bin/sh: line 1: wmenu: command not found
[ERROR] - wlroots: no output found

The no output found error is the result of pressing Escape: an empty chooser selection makes that capture request fail. Slack coped with it -- sharing the full screen still worked afterwards -- but having to dismiss a focus-stealing prompt twice per share is not a good workflow.

The trigger was probably the sway 1.11 to 1.12 upgrade -- before that, no chooser ever appeared. Slack itself (started with --enable-features=WebRTCPipeWireCapturer --ozone-platform=wayland) was unchanged.

Step 1: pin the chooser

xdg-desktop-portal-wlr reads ~/.config/xdg-desktop-portal-wlr/config. Pinning the chooser to slurp replaces the bar takeover with a crosshair where I click on the monitor I want to share:

[screencast]
chooser_type = simple
chooser_cmd = slurp -f %o -or

Then restart the portal: systemctl --user restart xdg-desktop-portal-wlr.

This worked, but now I had to click multiple times per share: Slack/Electron makes multiple portal requests (two for the live preview in the share dialog, one more when actually clicking "Share"), and the portal runs the chooser for every request. xdg-desktop-portal-wlr does not support the portal restore-token mechanism, so it cannot remember the previous answer.

Step 2: cache the answer

So I wrapped slurp in a small script that asks once and reuses the answer for repeated requests within 60 seconds -- ~/bin/xdpw-output-chooser:

#!/bin/sh
# Output chooser for xdg-desktop-portal-wlr: ask once via slurp,
# reuse the choice for repeated requests within 60 seconds
# (Slack/Electron fires several capture requests per share).

cache="${XDG_RUNTIME_DIR:-/tmp}/xdpw-output-choice"

if [ -f "$cache" ]; then
    age=$(( $(date +%s) - $(stat -c %Y "$cache") ))
    if [ "$age" -lt 60 ]; then
        cat "$cache"
        exit 0
    fi
fi

choice=$(slurp -f %o -or) || exit 1
printf '%s\n' "$choice" | tee "$cache"

And point the portal config to it:

[screencast]
chooser_type = simple
chooser_cmd = /home/mfa/bin/xdpw-output-chooser

Now starting a screenshare is: open the share dialog, click the monitor once in slurp, the preview appears, click "Share" -- done. One click instead of three, and nothing steals the keyboard focus anymore.

Download GeForce Now Playtime

GeForce Now shows your session history on the account page, but there is no export button. I wanted my playtime data as JSON so I can analyse it later. So I built a small CLI tool that fetches the session history from NVIDIA's API and appends it to a local file.

The tricky part

The API at api-prod.nvidia.com requires an idtoken header -- a JWT issued by login.nvidia.com. The token expires after one hour and there is no public way to get one programmatically. No OAuth client credentials, no API key, nothing.

The solution is Playwright with a persistent browser context. The script opens the GFN account page, waits for the page to make an API call, and intercepts the token from the request headers:

def get_token_via_browser() -> str:
    from playwright.sync_api import sync_playwright

    captured_token = None

    def on_request(request):
        nonlocal captured_token
        if "api-prod.nvidia.com" in request.url and not captured_token:
            token = request.headers.get("idtoken")
            if token:
                captured_token = token

    BROWSER_DATA_DIR.mkdir(exist_ok=True)

    with sync_playwright() as p:
        context = p.chromium.launch_persistent_context(
            str(BROWSER_DATA_DIR), headless=False,
        )
        page = context.pages[0] if context.pages else context.new_page()
        page.on("request", on_request)
        page.goto(GFN_ACCOUNT_URL, wait_until="domcontentloaded")

        page.wait_for_event(
            "request",
            predicate=lambda r: (
                "api-prod.nvidia.com" in r.url
                and r.headers.get("idtoken")
            ),
            timeout=120_000,
        )
        context.close()

    return captured_token

Because the context is persisted in .browser-data/, you only need to log in once. On subsequent runs Playwright reuses the session cookies and the token gets captured without interaction.

The API

The session history endpoint is /gfn-paywall-api/api/v2/userplaytime/sessionshistory. It takes spanStartDate and spanEndDate as query parameters and returns something like this:

{
  "sessionHistory": [
    {
      "sessionStartDate": "2026-04-11T17:58:01.000Z",
      "sessionEndDate": "2026-04-11T20:59:52.000Z",
      "gameId": "100358911",
      "gameTitle": "Baldur's Gate 3",
      "totalPlaytime": 181.0
    }
  ]
}

One catch: the API only returns data within a single billing period. If your date range spans multiple months you get incomplete results. The fix is to chunk requests by calendar month:

chunk_start = start.replace(day=1)
while chunk_start < end:
    if chunk_start.month == 12:
        chunk_end = chunk_start.replace(year=chunk_start.year + 1, month=1)
    else:
        chunk_end = chunk_start.replace(month=chunk_start.month + 1)
    params["spanStartDate"] = chunk_start.strftime(...)
    params["spanEndDate"] = min(chunk_end, end).strftime(...)
    # fetch and collect
    chunk_start = chunk_end

Sessions at month boundaries can appear in both adjacent chunks, so the results need to be deduplicated.

Usage

Example output for me:

$ uv run nvidia-playtime
Opening GFN account page...
If prompted, log in with your NVIDIA account.
Token captured.

Fetching sessions: 2026-03-13 to 2026-04-12
  2026-03-01 .. 2026-04-01
  2026-04-01 .. 2026-05-01
Added 28 new sessions, 28 total in sessions.json

Date                 Game                                     Duration
----------------------------------------------------------------------
2026-03-14 18:53:35  Baldur's Gate 3                          3h 11m
2026-03-14 23:17:01  Baldur's Gate 3                          1h 34m
...
2026-04-11 17:58:01  Baldur's Gate 3                          3h 01m
----------------------------------------------------------------------
Total                                                         48h 12m

28 sessions

Running it again only appends new sessions.

What I learned

NVIDIA has a second endpoint at /userplaytime/history that takes a memberSince parameter. It sounds like it should return the full history, but it only goes back about 6 weeks and doesn't include game titles. Not useful.

Using Playwright to intercept tokens from a real browser session is a nice pattern for APIs that don't offer proper auth for scripts. This is especially relevant because of the second factor, which is an email with a link you need to open. The persistent context makes it almost seamless after the first login.

Stats for owned Steam Games

There are websites out there to analyse your Steam library. But I actually don't want to give the data to someone else. I want a csv of my data to play around with. And building this is actually not that hard.

First we need to get the games from our profile. All games are listed here https://steamcommunity.com/id/YOUR_STEAM_ID/games/?tab=all. Then download the HTML. Only the HTML, the images are not important.

The full Python code to convert the HTML file to a csv:

import calendar
import csv
import re
import sys
from datetime import datetime
from html import unescape

months = {name: i for i, name in enumerate(calendar.month_abbr) if name}
months["Sept"] = 9

def parse_playtime(raw):
    m = re.match(r"([\d.]+)\s+minutes", raw)
    return f"{float(m.group(1)) / 60:.1f} hours" if m else raw

def parse_date(s):
    m = re.match(r"(\d{1,2})\s+(\w+)\s*(\d{4})?", s)
    if not m or m.group(2) not in months:
        return s
    day = int(m.group(1))
    month = months[m.group(2)]
    year = int(m.group(3)) if m.group(3) else datetime.now().year
    return f"{year}-{month:02d}-{day:02d}"

def extract(pattern, text):
    m = re.search(pattern, text)
    return m.group(1).strip() if m else ""

parts = re.split(r'<img alt="([^"]+)"', sys.stdin.read())
writer = csv.writer(sys.stdout)
writer.writerow(["Name", "Playtime", "Last Played", "Achievements"])

for name, rest in zip(parts[1::2], parts[2::2]):
    if not rest.startswith(' loading="lazy"'):
        continue

    played = extract(r"TOTAL PLAYED</span>([^<]+)", rest)
    last = extract(r"LAST PLAYED</span>([^<]+)", rest)
    achievements = extract(r"ACHIEVEMENTS</a><span[^>]*>([^<]+)", rest)

    writer.writerow(
        [
            unescape(name),
            parse_playtime(played) if played else "",
            parse_date(last) if last else "",
            achievements,
        ]
    )

The script is called like this: python convert.py < Steam\ Community\ __\ YOURUSER\ __\ Games.html > games.csv.

Some explanations for the code: First the helper functions. The parse_playtime function is needed to convert the minutes to hours to have only one decimal number in the Playtime column. The second function parse_data convert the dates to the one correct date format: ISO 8601. Steam uses "Sept" for dates instead of "Sep", which is annoying, but can be easily fixed. And finally the extract function is the multiple times used regex to get the actual values from the HTML. The for-loop searches for img elements that have an "alt" set. At first I used the CSS classes but they look generated. The image will always be there, so this should reliably work for some time in the future.

The last played column can have values like today or yesterday in there. This wasn't important enough for me to actually fix.

The csv looks like this:

Name,Playtime,Last Played,Achievements
<game1>,136.8 hours,2020-05-23,60/91
<game2>,92.4 hours,2025-10-06,26/26
<game3>,26.7 hours,2025-03-13,41/44
<game4>,17.2 hours,2023-06-07,10/10

I don't want to leak the games I play, so I redacted the names. And obviously I have a lot more lines in my csv file.

Later I used the csv to build a script that outputs some stats in Markdown. For example this one:

## Games Last Played by Year

| Year | Count | |
|------|------:|-|
| 2013 |     1 | |
| 2014 |     3 | ██ |
| 2015 |    21 | ███████████████████ |
| 2016 |    16 | ███████████████ |
| 2017 |    22 | ████████████████████ |
| 2018 |     6 | █████ |
| 2019 |     8 | ███████ |
| 2020 |    26 | ████████████████████████ |
| 2021 |    18 | ████████████████ |
| 2022 |    22 | ████████████████████ |
| 2023 |    26 | ████████████████████████ |
| 2024 |    32 | ██████████████████████████████ |
| 2025 |    29 | ███████████████████████████ |
| 2026 |    12 | ███████████ |

I used the csv to ask Claude what games I should play next. The list Claude returned had no real surprises in there. Now I only have to find the time and mood to actually play them. 😀