This guide is for anyone who wants SwitchSaveSync to talk to a cloud it doesn't know yet — OneDrive, Dropbox, pCloud, Backblaze B2, whatever — and is willing to write the code. You don't need to ask me first: the app was built so that this is one new file, not surgery.
If you just want to use a server of your own (Nextcloud, a Synology or QNAP NAS, ownCloud, Box), you don't have to write anything: WebDAV is already there, under Settings → Where to save. This guide is for clouds that speak their own API.
Em português: NUVENS.md.
One new file in core/, twelve functions, and three lines to register them. Nothing
else in the app changes — not the game list, not the syncing, not the conflict handling,
not .nxsaves.
That works because the hard part is already written and belongs to no cloud in particular.
Uploading a whole folder recursively, mirroring it, deleting what disappeared from the
console, pulling the tree back down — all of that lives in core/cloud.c
and is written exactly once, on top of seven primitives. It used to be half of
drive.c and there wasn't a line of Google in it.
You implement the seven primitives. The rest you get for free.
Don't start from scratch. Pick by the shape of the id:
| Your cloud identifies a file by... | Copy | Why |
|---|---|---|
path (/SwitchSaveSync/Zelda/save.dat) |
core/webdav.c |
Already treats the id as a path, and it's the smaller of the two |
opaque id (1a2B3c4D...) |
core/drive.c |
Already handles opaque ids, paging and OAuth |
Dropbox and OneDrive accept both styles. I'd take the opaque id in both: renaming a folder from the cloud's website then breaks nothing, because the id doesn't change.
Call the file core/onedrive.c and core/onedrive.h, in the same shape as its neighbours.
You don't need to touch a Makefile. All three (
gui,app,sysmodule) listSOURCESas a directory, not a file list. A new.cincore/joins the build on its own.
The table below is the contract. It's declared in
core/cloud_backend.h — read that too, the comments say more.
| Field | What to return |
|---|---|
key |
The short word written into nuvem.cfg. Lowercase, no spaces: "onedrive". Never change it after release, or everyone who picked that cloud silently falls back to Drive. |
name() |
The name on screen: "OneDrive". It's a function, not a fixed string, because it goes through TR() — and a static struct field can't hold a choice made at runtime. |
is_ready() |
true if it's usable right now (login saved, server configured). The screen shows ready clouds differently from ones still to be set up. |
setup_hint() |
One short sentence saying what's missing when is_ready() is false: "Not signed in yet". It shows under the name. |
logout() |
Wipe the saved login from the SD card. It must leave is_ready() false. |
| Field | What to return |
|---|---|
begin(auth, authsz) |
Open a session and write into auth whatever the other functions will need. On Drive that's the access_token; on WebDAV it's the ready-made Basic header. The buffer is CLOUD_AUTH_MAX (2048) — Google's token is over 1 KB, so don't shrink that. This is also where you refresh an expired token. |
All of them take the auth from begin() as their first argument.
| Field | Contract |
|---|---|
root(auth, id_out, outsz) |
The id of the app's root folder, creating it if it doesn't exist. It's the only one allowed to create without being asked. |
find_child(auth, parent_id, name, want_folder, id_out, outsz) |
Find a direct child of parent_id named name. want_folder separates folders from files — without it, a game named the same as a file would be confused for it. Not finding it is not an error: return false and move on. |
make_folder(auth, parent_id, name, id_out, outsz) |
Create the folder and return its id. |
put_file(auth, parent_id, name, local_path, mime) |
Upload the local file. If one with that name is already there, replace it — don't duplicate. This is mandatory: without it, every sync would leave another copy behind. |
get_file(auth, id, local_path) |
Download to the local path. |
remove(auth, id) |
Take it off the cloud. Prefer a trash bin over a hard delete if your cloud has one: this touches save backups, and a mistake has to be undoable. |
list(auth, folder_id, cb, userdata) |
Call cb once per direct child, with (id, name, is_folder, userdata). Handle paging — nearly every API returns the first N plus a cursor for the rest. A folder of 300 saves listed halfway turns into deleted saves during prune. |
Put it all in a CloudBackend instance and return the pointer, like the bottom of
webdav.c:
static const CloudBackend g_onedrive_backend = {
.key = "onedrive",
.name = onedrive_be_name,
.is_ready = onedrive_be_is_ready,
.setup_hint = onedrive_be_setup_hint,
.logout = onedrive_be_logout,
.begin = onedrive_be_begin,
.root = onedrive_be_root,
.find_child = onedrive_be_find_child,
.make_folder = onedrive_be_make_folder,
.put_file = onedrive_be_put_file,
.get_file = onedrive_be_get_file,
.remove = onedrive_be_remove,
.list = onedrive_be_list,
};
const CloudBackend *onedrive_backend(void) { return &g_onedrive_backend; }1. In core/cloud.h, in the CloudKind enum, before CLOUD_COUNT:
typedef enum
{
CLOUD_DRIVE = 0,
CLOUD_WEBDAV,
CLOUD_ONEDRIVE, // <-- here
CLOUD_COUNT,
} CloudKind;Order matters and can't be shuffled later. Anyone with a saved
nuvem.cfgis found again bykey, not by number — but always append at the end, beforeCLOUD_COUNT. Slipping one into the middle changes the value of everything after it.
2. In core/cloud_backend.h, the declaration:
const CloudBackend *onedrive_backend(void);3. In core/cloud.c, inside backend_of():
case CLOUD_ONEDRIVE: return onedrive_backend();That's it. cloud_name(), cloud_is_ready(), cloud_set_current() and the nuvem.cfg
handling already walk the whole enum — they learn about your cloud without you touching
them.
In gui/source/main.cpp, in the Settings tab, under "Where to save", there's one row
per cloud. Copy the g_webdav_item pattern: a row that opens that cloud's setup screen.
If your cloud signs in with a device code, the Google login screen is a ready-made template — it shows the code, the link and a QR code, then waits. If it's username and password, the WebDAV screen is the template.
This is the part that saves the most time, and it's easy to miss that it exists at all.
The whole of core/ runs on a Mac or on Linux. The trick is two #defines:
SYNC_APP_DIR, in core/syncstate.h, is "sdmc:/switch/SwitchSaveSync",
and CA_BUNDLE_PATH, in core/http.c, is "romfs:/cacert.pem". Neither
starts with / — so on a computer they are relative paths. Create directories with
those literal names (sdmc: and romfs:, colon and all) and the core code runs unmodified,
TLS certificate verification included.
Look at the drive block in tests/run.sh: it builds those directories,
points romfs:/cacert.pem at the project's own certificate bundle, and runs the suite
against the real cloud. Copy that block, swapping test_drive.c for yours.
./tests/run.sh # everything that needs no account and no internet
./tests/run.sh drive # talks to the real Google DriveIt all compiles with ASan and UBSan on. A buffer overrun that on the console becomes an unexplained crash becomes, here, a message with a line number.
One rule, and it isn't negotiable: only test deletion inside an isolated test folder.
cloud_prune_extrasand the backend'sremovereally do delete. Never point a destructive test at somebody's save folder.
OneDrive works, and it's the closest to what already exists: the same device flow as Google, and on top of that, no client secret.
- portal.azure.com → Microsoft Entra ID → App registrations → New registration.
- Account types: "Accounts in any organizational directory and personal Microsoft accounts" — without this, a personal account (which is the one with ordinary OneDrive) can't sign in.
- Under Authentication, turn on "Allow public client flows". That switch is what enables the device flow.
- Under API permissions → Microsoft Graph → Delegated permissions, add
Files.ReadWrite.AppFolderandoffline_access. - Note the Application (client) ID. No secret needed — public clients don't use one, and that's a real advantage over Google here.
Use
Files.ReadWrite.AppFolder, notFiles.ReadWrite. It's the equivalent of Google'sdrive.file: the app sees only its own folder and nothing else in the person's OneDrive. Asking for more than you need is how a save-sync app becomes an app that reads everything.
Ask for the code — {tenant} is consumers for a personal account, common if you also
want to accept work accounts:
POST https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode
Content-Type: application/x-www-form-urlencoded
client_id=YOUR_ID&scope=Files.ReadWrite.AppFolder%20offline_access
Back come user_code (what the person types), verification_uri (where they type it),
device_code (what you keep), expires_in and interval.
Then you poll, waiting interval seconds between attempts:
POST https://login.microsoftonline.com/consumers/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:device_code&client_id=YOUR_ID&device_code=...
Until the person finishes, you get errors — and each one wants a different reaction:
| Error | What to do |
|---|---|
authorization_pending |
Normal. Wait and ask again. |
authorization_declined |
They refused. Stop polling. |
expired_token |
Past expires_in. Stop, and offer to start over. |
bad_verification_code |
You sent the wrong device_code. That's your bug. |
On success you get an access_token (good for about an hour) and a refresh_token (the one
you write to the SD card, because it's the one that survives). The refresh_token only
comes if you asked for offline_access in the scope.
core/oauth.c already does exactly this dance for Google, including the
waiting, cancelling with B, and refreshing. That's the file to copy.
Base: https://graph.microsoft.com/v1.0, with Authorization: Bearer <access_token>.
| Primitive | Call |
|---|---|
root |
GET /me/drive/special/approot → the id field. The folder is created the first time you call it. |
find_child |
GET /me/drive/items/{parent}:/{name} — a 404 means "not there", which is not an error. To tell a folder from a file, check whether the object carries the folder facet. |
make_folder |
POST /me/drive/items/{parent}/children with {"name":"X","folder":{},"@microsoft.graph.conflictBehavior":"fail"} |
put_file |
PUT /me/drive/items/{parent}:/{name}:/content — see the size warning below |
get_file |
GET /me/drive/items/{id}/content — answers 302 with a temporary download URL, so curl has to follow redirects (CURLOPT_FOLLOWLOCATION). |
remove |
DELETE /me/drive/items/{id} — goes to the OneDrive recycle bin, which is the behaviour we want. |
list |
GET /me/drive/items/{id}/children — it pages. While the response carries @odata.nextLink, fetch the next page. |
1. A plain PUT only goes up to 4 MB. Above that Graph refuses, and Switch saves pass
that without trying — Zelda, Animal Crossing, any big game. For anything larger you need an
upload session: POST /me/drive/items/{parent}:/{name}:/createUploadSession, then send
chunks with PUT to the uploadUrl you got back, each carrying a Content-Range. Use
multiples of 320 KiB for the chunks — that's what Microsoft asks for.
Don't fall for testing with a small save only and concluding it works.
2. Content-Type is mandatory on the PUT. Without it Graph answers 400 — or worse,
accepts it and stores a corrupted file. Use application/octet-stream. put_file already
receives a mime_type parameter; pass it through.
Also doable, and the simplest of the three: the /2/files API works by path, not by id,
so find_child becomes string concatenation and root is a constant.
- Create an app at dropbox.com/developers → Scoped
access → App folder (not "Full Dropbox" — same idea as
Files.ReadWrite.AppFolder: the app only ever sees its own folder). - Permissions:
files.content.writeandfiles.content.read. - Dropbox's device flow is more limited than Microsoft's; the normal route is PKCE. If you don't want a browser screen on the console, you can generate a long-lived token in the dashboard and have the person paste it — ugly, but honest, and the typing screen already exists in the WebDAV setup.
Endpoints: https://api.dropboxapi.com/2/files/* for metadata operations (create folder,
list, delete) and https://content.dropboxapi.com/2/files/* for upload and download. There's
paging here too: list_folder returns a cursor, and you call list_folder/continue while
has_more is true.
And it isn't for lack of trying.
Apple has no public third-party API for iCloud Drive. What exists is CloudKit, which only answers to an app carrying an Apple identity, and CloudKit Web Services, which requires a paid Apple Developer Program membership. That would run straight into the one non-negotiable rule of this project: zero cost for whoever uses it.
If that ever changes, the place to work is the same as for the others: a core/icloud.c and
three lines of registration.
./tests/run.shpassing (that's the floor, and it needs no account to run).- A test of your own in the shape of
tests/test_drive.c, pointed only at a test folder. make -C guiwith no new warnings.- If your cloud needs a credential, it goes in
core/config.h— which is gitignored. Put the how-to-get-yours steps incore/config.h.example, the way it's done there for Google. - Never commit a credential, not even "just to test". If one slips out, treat it as burnt and issue another: bots sweeping GitHub commits for keys work in minutes, not days.
Open an issue if you get stuck — preferably before writing the whole thing, since course corrections are cheaper early.