Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,12 @@ each with a package doc comment stating its contract:

- `model` — domain types and the identity rules. Carries `id` (the store product id) and
deliberately not `productId`, which is a different value no endpoint accepts.
- `config` — user settings by precedence: defaults → `config.toml` → env → flags. There is
no browser session default, because a browser session cannot work here.
- `session` — builds the Cookie header from a pasted curl file or a `cookies.txt`, and
asserts the `LS` cookie is present before any request.
- `config` — user settings by precedence: defaults → `config.toml` → env → flags.
- `session` — builds the Cookie header from a Firefox-family session store, a pasted curl
file, or a `cookies.txt`, and asserts the `LS` cookie is present before any request. The
source is identified by reading it, not by configuration. `mozlz4.go` decodes Gecko's
compressed session store; the jar it holds spans every host the browsing session touched,
so it is filtered to `unity.com` before anything leaves the package.
- `retry` — backoff policy. `retry.Permanent` lets a caller stop on a body-based verdict
that the status code alone would have retried.
- `unitypackage` — reads the store descriptor from a package's gzip FEXTRA field.
Expand All @@ -57,7 +59,11 @@ each with a package doc comment stating its contract:

- **`LS` is the credential.** Not the NextAuth session token, which neither endpoint
consults. Its absence is reported before any request, because the store answers a
missing `LS` with an opaque 500.
missing `LS` with an opaque 500. It is absent from `cookies.sqlite` but present in a
Gecko session store, which is what makes the browser source possible.
- **No cookie value is ever logged**, and a session store is filtered to the `unity.com`
family inside `internal/session`. That file carries credentials for every host the
browsing session touched.
- **No store client follows a redirect.** An unauthenticated download 302s to Unity's
OAuth page. `selfupdate` is the deliberate exception: it talks to GitHub, whose asset
API 302s to a signed CDN URL by design.
Expand Down
42 changes: 34 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,33 @@ default; override with `library_path`, `UNITY_SYNC_LIBRARY`, or `--library`.
## Session

The store gates everything behind your signed-in session, and the cookie it actually
checks `LS` is a session cookie that lives only in your browser's memory. No browser
cookie database has it, so unity-sync cannot read your session automatically the way a
tool for some other store might. You paste one instead:
checks is `LS`. It is a session cookie, so `cookies.sqlite` never holds it — but a
Firefox-family session store does, because Gecko records the cookies of every host the
browsing session touched.

In DevTools → Network, right-click any `assetstore.unity.com` request → Copy → Copy as
cURL, and save it:
**If you use Firefox, Zen, LibreWolf, Waterfox or Floorp**, sign in to the Asset Store once
in that browser and point unity-sync at it:

```toml
# ~/.config/unity-sync/config.toml
session_source = "browser"
```

```bash
unity-sync status # no paste, no expiry to babysit
```

It reads only the `unity.com` cookies out of the session store and discards the rest of the
file. You do not need to keep an Asset Store tab open; the cookie lasts as long as the
browsing session does. `--session browser` does the same thing for one run, and the run
prints which profile it read.

Chromium-family browsers keep session cookies somewhere else entirely, encrypted, so they
are not supported.

**Otherwise, paste a session.** In DevTools → Network, right-click any
`assetstore.unity.com` request → Copy → Copy as cURL, and save the whole thing verbatim —
no extracting values, no escaping:

```bash
$EDITOR ~/.config/unity-sync/session.curl # paste, save
Expand All @@ -75,10 +96,15 @@ A Netscape `cookies.txt` export works too, as long as your exporter keeps HttpOn
`session_source` in `config.toml`, or just save it as `session.curl` or `cookies.txt` in
the config dir, where unity-sync looks by default.

If the file is missing the `LS` cookie, unity-sync says so before making any request,
because the store's own answer in that case is an HTTP 500 that reads like a server fault.
`--session` also takes a browser profile directory or a `recovery.jsonlz4` straight, which
covers a Gecko browser this does not know where to look for. Whatever you point it at,
unity-sync works out what the file is by reading it.

If the session has no `LS` cookie, unity-sync says so before making any request, because
the store's own answer in that case is an HTTP 500 that reads like a server fault.

A pasted session expires. When it does, re-copy it.
A pasted session expires. When it does, re-copy it, or switch to `session_source =
"browser"` and stop re-copying.

## Commands

Expand Down
3 changes: 3 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
#
# There is no browser option. The cookie the store checks is a session cookie that never
# reaches a browser's cookie database, so reading one could not authenticate.
# session_source = "browser" reads a signed-in Firefox-family session store, which is the
# only way to avoid re-pasting when the session expires. It also accepts a path to a pasted
# curl file, a cookies.txt, a browser profile directory, or a recovery.jsonlz4.
# session_source = "~/.config/unity-sync/session.curl"

# Where packages are cached. Default: $XDG_DATA_HOME/unity-sync
Expand Down
24 changes: 21 additions & 3 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,27 @@ The GraphQL body is a batch: a JSON array of operations, answered by a positiona
full owned list; a junk or absent `__Secure-next-auth.session-token` changes nothing;
removing `LS` turns any user-scoped query into an HTTP 500 with an empty `GraphqlError`.

`LS` is a session cookie, so it never reaches a browser's cookie database. That is why the
only supported session sources are a pasted curl command and a `cookies.txt` export, and
why there is no browser-reading mode: it could not work.
`LS` is a session cookie, so it never reaches `cookies.sqlite`. It does reach a
Firefox-family **session store**, which is a different file for a different purpose:
Gecko's `sessionstore-backups/recovery.jsonlz4` records the cookies of every host the
browsing session touched so the session can be restored. Measured on a real profile: `LS`
and `_csrf` are both in there, and the two of them alone return HTTP 200 with the full
owned list.

Two properties decide what that can promise. The jar is **not tab-scoped** — on the profile
measured, 156 of 169 cookie hosts had no tab open anywhere in the session, and
`assetstore.unity.com` was one of them — so the credential survives closing the tab and
lasts as long as the browsing session. And the file is rewritten **periodically**, not on
every cookie change, so it lags a sign-in by seconds.

The supported sources are therefore a session store, a pasted curl command, and a
`cookies.txt` export. Which one a path is gets decided by reading it: a session store is
identified by its `mozLz40\0` magic, a curl paste by its structure. Chromium keeps session
cookies in an encrypted SQLite database instead, so it is out of scope.

The session store is read narrowly on purpose. It holds credentials for every host the
session touched, so `internal/session` filters to the `unity.com` family before anything
leaves the package, and no cookie value is ever logged.

The `_csrf` cookie is a double-submit token required by the GraphQL endpoint only. Not
every storefront route issues it — `/` and `/publishers/{id}` answer 200 and set nothing,
Expand Down
3 changes: 2 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import (

// Config is the resolved user-scoped configuration.
type Config struct {
// SessionSource is a path to a pasted-curl file or a cookies.txt.
// SessionSource is "browser" to read a signed-in Firefox-family session, or a path to
// a pasted-curl file, a cookies.txt, a browser profile, or a recovery.jsonlz4.
SessionSource string

// LibraryPath is where packages are mirrored. A user may point this at Unity's own
Expand Down
Loading