Export photos and videos from game consoles, cameras, and drives to a Mac. Free, open-source Mac app and CLI.
Tethersnap started as the missing Switch 2 bridge. macOS has no MTP support, and the Android-oriented bridges (OpenMTP, MacDroid, Android File Transfer) fail against the console, which is a plain PTP 1.00 responder with no MTP vendor extension (it implements exactly the ten baseline operations 0x1001-0x100A). Tethersnap is a from-scratch minimal PTP/MTP initiator over IOUSBHost that speaks only baseline PIMA 15740, the same subset libmtp uses successfully on Linux, validated end to end against real hardware (firmware 22.5.0): connect, enumeration, thumbnails, and byte-exact exports. That baseline is what every PTP camera speaks too, so Tethersnap lists any still-image-class USB device, and it reads mounted drives and memory cards for the consoles that have no USB mode at all.
- DMG: get
Tethersnap-<version>.dmgfrom the latest release, open it, and drag Tethersnap into Applications. The app is Developer ID signed and notarized, so it opens without a Gatekeeper warning. - Homebrew:
brew install --cask luminoid/tap/tethersnap(from Luminoid/homebrew-tap).
Requirements: macOS 15 (Sequoia) or later, and a full-data USB-C cable for USB sources. No drivers, no kernel extensions, no MTP stack to install.
How it works, in depth: Tethersnap: Switch 2 captures on a Mac over plain PTP.
Scaffolded with Monolith.
| Source | How it connects | Notes |
|---|---|---|
| Nintendo Switch 2 | USB, in Copy to PC mode | Tested on firmware 22.5.0 and 23.0.1; captures group by game |
| Nintendo Switch | USB, in Copy to PC mode (system software 11.0 or later) | Same protocol, recognized by its USB ID |
| Cameras (DSLR, mirrorless) | USB, in the camera's PTP or MTP mode | Any still-image-class device. RAW files are listed, previewed, and exported untouched. In Image Capture, set the camera's "Connecting this camera opens" to "No application" so macOS does not hold it |
| PS5, PS4 | A USB drive the console wrote to (PS5/CREATE/…, PS4/SHARE/…) |
These consoles have no USB mode for a Mac. Captures group by game; WebM clips export as they are. The PS5 saves a 1920×1080 poster frame beside every clip (<Game>_<stamp>.jpg next to the .mp4); Tethersnap lists and exports it as a photo of its own |
| Switch SD card | The card in a reader (Nintendo/Album/…) |
Groups by date |
| Camera cards | The card in a reader (DCIM/…, plus Sony's PRIVATE/M4ROOT/CLIP and AVCHD STREAM clip folders) |
Groups by folder (100CANON, CLIP) |
| Steam Deck, anything else | Any mounted drive, or File › Open Folder… (⌘O) | Screenshots copied off the Deck over SSH (see Usage), a phone backup, a folder of old exports |
iPhones and iPads expose a PTP interface too and appear in the sidebar; they are untested and never opened automatically.
On a Switch: System Settings → Data Management → Manage Screenshots and Videos → Copy to PC via USB, connected from the console's bottom USB-C port (not the dock) with a full-data cable. On a camera: choose its PTP or MTP USB mode.
On a Steam Deck: the Deck has no USB mode for a Mac, and its microSD card is ext4, which macOS cannot read, so copy over Wi-Fi. Screenshots live in ~/.local/share/Steam/userdata/<account>/760/remote/<app id>/screenshots/, one numeric app-ID folder per game with a thumbnails folder beside the files. The Shotty plugin for Decky Loader (both independent projects; Decky is installed in Desktop mode, see its installation guide, and Shotty comes from Decky's plugin store) hardlinks every screenshot into ~/Pictures/Screenshots/<Game>/, named by game, which is the layout Tethersnap groups best. Two things to know about it: it starts with the screenshots taken after it was installed, so press its Aggregate button once to bring in the older ones (Quick Access menu, the … button, then the Decky plug icon at the bottom of the tab strip, then Shotty); and a hardlink survives deletion in Steam's Media view, so a screenshot deleted there stays in Shotty's folder until it is deleted there too (in Desktop mode, with Dolphin). Then: on the Deck, press the Steam button, choose Power → Switch to Desktop, and open Konsole from the application launcher (under System). Turn SSH on there, copy from the Mac, and turn it off again when done:
# 1. on the Deck, in Konsole
passwd # set a password if there is none yet (SteamOS ships without one)
sudo systemctl start sshd # SSH on until the Deck sleeps or restarts
ip -4 addr show wlan0 | grep inet # the Deck's IP address, in case steamdeck.local does not resolve
# 2. on the Mac, with the Deck awake; enter the Deck's password when asked (--delete mirrors removals, so the copy matches the Deck)
rsync -a --delete --exclude most_recent.jpg deck@steamdeck.local:Pictures/Screenshots/ ~/Pictures/Tethersnap/Steam\ Deck/ # most_recent.jpg is Shotty's copy of the newest shot
# without Shotty: Steam's own folders, grouped by app ID
rsync -a --delete --exclude thumbnails "deck@steamdeck.local:.local/share/Steam/userdata/*/760/remote/" ~/Pictures/Tethersnap/Steam\ Deck/
# 3. on the Deck, when done
sudo systemctl stop sshd # SSH off; `sudo systemctl enable --now sshd` instead of start keeps it on across restarts, `disable --now` undoes thatFinally pick Return to Gaming Mode on the desktop, and on the Mac choose File › Open Folder… on the copy.
Group by Folder then shows one group per game (with Steam's own folders, every file sits in a screenshots folder, so they all group together), and Fix Dates reads the YYYYMMDDhhmmss_1 stamps. Game Recording clips are exported in Desktop mode (Library › View › Recordings & Screenshots › Share › Export Video File, into Videos by default) and copy the same way.
CLI:
swift run tethersnap sources # attached USB devices and mounted drives
swift run tethersnap probe # device / interface / storage diagnostics
swift run tethersnap list # list photos and videos
swift run tethersnap pull --all --out ~/Pictures/Tethersnap
swift run tethersnap pull --all --videos-only # narrow by kind, like 'list'
swift run tethersnap pull "Game/Game_20260830123456.jpg" # names as 'list' prints them
swift run tethersnap list --source 2 # a source by index or id from 'sources'
swift run tethersnap list --folder ~/Deck # any folder
swift run tethersnap fixdates ~/Pictures/Tethersnap --dry-runCtrl-C cancels a running pull or fixdates cleanly: the file in progress is removed, the summary still prints, and the command exits 130.
App:
make run-app # builds .build/Tethersnap.app and opens itThe app lists every attached source in a sidebar (devices, drives, folders), opens the first console or camera on its own, opens a drive when it mounts while nothing else is open, and closes the active source when it is unplugged or ejected (Cancel on the opening screen gives up on a slow device). It shows a thumbnail grid (device thumbnails over PTP, locally generated ones for files, a frame for MP4 and MOV clips) and supports multi-select (click / ⌘-click / shift-click ranges, ⌘A), keyboard navigation (arrows by item and by row, Space/Return or ⌘Y to preview, Escape), sorting, grouping by folder, and a View menu for all three. Preview is Quick Look for items on a drive or folder (videos play inline) and a retryable preview sheet for console items; file-backed items also get Show in Finder. Drag-out to Finder works per item or for a whole selection (dragged as one folder), and per-item / selection / Export All flows can be cancelled. Export destinations are chosen in a standard Open panel, and the layout follows the view: All Items puts everything into that folder, Group by Folder makes one subfolder per game or camera folder (a multi-item drag does the same); the last folder is remembered (⌘⇧E re-exports there, or asks on first run and when that folder is gone; see Settings to inspect or clear it), summaries distinguish saved from already-existing files, capture dates are preserved as file modification dates, and the Mac won't idle-sleep mid-export. A transfer failure or cancel abandons the PTP data phase, so the app throws the session away and reconnects automatically. Closing the window quits the app and releases the device.
Consoles, phones, and screenshot tools put the shot time in the filename (20260830123456…, Game_20260830123456.jpg, Screenshot 2026-08-30 at 12.34.56.png), and many of those files carry no date inside, so Photos and the Finder sort them by the day they were copied. File › Fix Dates from Filenames… (⌘⇧D, the calendar toolbar button, or tethersnap fixdates on the command line) picks a folder, preset to the export folder, and stores that timestamp as the capture date: EXIF and XMP for JPEG, PNG, TIFF, and HEIC; the QuickTime creation date for MP4, M4V, and MOV, written by patching the movie header and metadata box in place, so nothing is re-encoded or re-muxed and every track stays; and the Finder created and modified dates for every matched file. RAW, WebP, GIF, BMP, WebM, AVI, MTS, and M2TS files get Finder dates only, their bytes are never rewritten. A capture date already embedded in a file (EXIF DateTimeOriginal or DateTimeDigitized, XMP CreateDate) is kept unless you choose Overwrite existing dates; a file that carries only a modification time, as the original Switch's JPEGs do, gets a capture date. Sony clips come with a record (C0001M01.XML beside C0001.MP4, and a copy inside the clip) holding the exact start time with its time zone, the camera make and model, and the position when the camera had a fix (its own GPS, or a linked phone); Fix Dates embeds those too, as the keys Photos and the Finder read, so Photos shows the camera and the map, unless you turn off Use the camera's clip record (--no-clip-metadata).
The UI and error messages are localized in English and Simplified Chinese (following the system language). Strings live in classic .lproj/Localizable.strings files because swift build copies .xcstrings catalogs without compiling them.
The app writes a complete debug trace of every run to ~/Library/Logs/Tethersnap/Tethersnap.log (the run before it is kept as Tethersnap.previous.log), with no setup: just reproduce the problem and grab the file, or use Help → Reveal Log File in Finder. Each line carries an ISO-8601 timestamp, the level, and the category, and the first line of a run names the app version, build, and macOS version. If the file cannot be created, the status bar says so and logging continues in the unified log.
Everything also lands in the unified log under subsystem dev.luminoid.Tethersnap (categories usb, mtp, library, volume, dates, app). Levels follow os.Logger: notice and above (sessions, connects, export outcomes, warnings, errors) are saved on disk and can be read after the fact, info is kept in memory for a while, and debug (hex previews, per-transaction traces) is written only while the CLI's --verbose or the app's log file is on. Errors are logged with their domain and code. File paths, filenames, and drive or folder names are private: the unified log shows them as <private>, while the log file, which stays on your Mac, keeps them in full.
Read the last hour after the fact:
/usr/bin/log show --last 1h --info --debug --predicate 'subsystem == "dev.luminoid.Tethersnap"'Or watch live:
/usr/bin/log stream --level debug --predicate 'subsystem == "dev.luminoid.Tethersnap"'Use the full /usr/bin/log path in zsh, whose log builtin shadows the command.
The CLI's --verbose flag mirrors every line to stderr with a local timestamp and the category (12:23:12.285 [debug] usb: bulk-in …), covering USB transfer hex previews and PTP transactions with response codes. It is the fastest way to see where a conversation with a device stops:
swift run tethersnap probe --verboseThe CLI writes error: and warning: lines to stderr and exits non-zero on failure (including a pull whose named files are all missing or that could not save every file, or a fixdates with a failed file), so stdout stays clean for scripts; progress lines are printed only on a terminal.
| Target | Kind | Notes |
|---|---|---|
| TethersnapKit | library | PTP containers/datasets, transaction engine, IOUSBHost transport, capture sources (PTP devices, volumes, folders), filename-date fixer |
tethersnap |
executable | CLI: sources / probe / list / pull / fixdates |
| TethersnapApp | executable | SwiftUI Mac app; bundled via make app |
brew bundle # install swiftlint + swiftformat
make setup-hooks # wire pre-commit lint + format
make build
make test # protocol-layer tests against a scripted mock transport, volume and date-fixer tests on temp files
make check # strict lint + format gate
make app # assemble .build/Tethersnap.app (release, ad-hoc signed)
make dist # Developer ID signed + notarized DMG into dist/The protocol layer is hardware-independent: MTPSession runs against any MTPTransport, and the tests script exchanges (split headers, coalesced containers, zero-length packets) through a mock. Volume layouts and the date fixer are tested against temporary directory trees and generated images and movies.
make dist runs the whole distribution pipeline: hardened-runtime Developer ID signature, notarize the app, staple, wrap in a DMG, notarize and staple the DMG, verify with Gatekeeper. Two one-time setups, and the target prints these instructions itself when either is missing:
- A Developer ID Application certificate in the keychain (create at developer.apple.com or via Xcode → Settings → Accounts → Manage Certificates; requires the Account Holder role).
- Stored notarization credentials:
xcrun notarytool store-credentials tethersnap-notary --apple-id <email> --team-id <team> --password <app-specific password>.
The result, dist/Tethersnap-<version>.dmg, is what a GitHub release attaches.
MIT. © Luminoid. See LICENSE and CHANGELOG.
Tethersnap is an independent open-source project. It is not affiliated with, endorsed, or sponsored by Nintendo, Sony Interactive Entertainment, or Valve. Nintendo Switch, Nintendo Switch 2, PlayStation, PS5, PS4, and Steam Deck are trademarks of their respective owners, used here only to describe compatibility.
The CLI is built on Apple's swift-argument-parser (Apache License 2.0); its license text ships in THIRD_PARTY_LICENSES.md and inside the DMG next to the app.
- Monolith: the CLI that scaffolded this project (iOS apps, Swift Packages, Swift CLIs)
- Everything else at luminoid.dev
