Synccit syncs your Reddit history between devices. Open a link on your phone, and it shows as read on your desktop, with the new comment count next to it. It is a small feature, and once you have it, going back feels broken.
The service itself is dead. The website is gone, the API endpoint answers nothing, and the clients that still ship the feature point at a dead URL. Boost for Reddit, the client I use on my phone, still has the Synccit settings screen. It just talks to https://api.synccit.com/api.php, which no longer exists.
So I rebuilt the server, then patched the client to point at my server. This is that story, including the parts that did not work.
What I started with
The original is synccit by James Wilson / drakeapps, a PHP 5.3 monolith of about 5,300 lines. I read it before writing anything. The core is api/api.php, revision 12: five modes (create, addauth, update, read, history) over JSON, XML, or plain text, plus a near-duplicate api/account.php for login tokens and an api/apps.php that lists supported apps. There is also a web UI. I did not port the web UI.
The logic that mattered most lives in one function, insertLinks(). A comment count only ever increases. A first-visit timestamp is only set if it is still zero. A both flag decides whether a record updates the link, the comments, or both. Get any of that wrong and two devices disagree about what you have read.
I chose Go and SQLite. A sync service for one person does not need a database server. It needs one file I can copy as a backup. The stack ended up as Go 1.27 with modernc.org/sqlite, pure Go, no CGO, WAL mode, a single writer connection.
The license question
Upstream has no LICENSE file. GitHub reports no license, there are no copyright headers in the source, and the only license notice is a GPLv3 mention at the tail of the README. A PHP-to-Go port is a translation of that code, which makes it a derivative work. MIT would only have been defensible as a clean-room reimplementation from the spec, with no copied code, comments, SQL, or text, and ideally with upstream permission. I did not do that. I chose GPL 3, same as upstream, and kept going.
Building the port
The build took one day, October 23, in four commits: scaffold with the SQLite store and auth, the three APIs, the CLI, then README, Dockerfile, Compose file, and CI. It is API-only. Accounts and device codes are managed with the CLI in the same binary:
CGO_ENABLED=0 go build -o synccit ./cmd/synccit./synccit user add james --email james@example.com./synccit device add james "my phone"./synccit serve --addr :8080Wire compatibility was the job. Old clients cannot be updated, so the server has to speak their protocol exactly. That meant keeping the .php paths, because the iOS client hardcodes api.php on a configurable base URL. Clean /api and /account aliases exist alongside them. It meant the same response shapes and error strings: JSON {"success":"4 links updated"}, {"error":"not authorized"}, string-typed read results, the XML <synccit> documents, the plaintext id:lastvisit;count:commenttime, rows, and the X-API, X-Revision, X-Success, and X-Error headers.
It also meant preserving quirks. The account API’s device delete answers “N links updated”, which is clearly a copy-paste mistake in the original, and I kept it, because a client might be parsing that string. JSON key order in the addauth and login success bodies differs from PHP. That one is semantically irrelevant, so I left it.
One deviation was deliberate. The original account API’s XML branch is an incoherent copy-paste that never supported login, devices, or history. I implemented the XML branch symmetric to the JSON branch.
Passwords, old and new
New passwords are hashed with argon2id. Old ones are the interesting part.
The original hashed with PBKDF2-HMAC-SHA512, 10,000 iterations, with the salt and hash stored in two separate columns. To import a legacy user you insert one row with the combined format:
INSERT INTO user (username, password_hash, email, created, createdby)VALUES ('james', 'pbkdf2-sha512$10000$<salt-column-value>$<passhash-column-value>', 'james@example.com', 1357891889, 'import');There is a trap in the original code: it feeds the stored base64 salt string itself as the PBKDF2 salt input, not the decoded bytes. The port reproduces that exactly, so imported hashes verify as-is. On the first successful password check, whether through addauth, the password-as-auth fallback, or account login, the row is transparently rehashed to argon2id. I verified that path with a test vector cross-checked against the original PHP algorithm, and the tests cover all three login paths.
Registration is locked by default. API account creation returns registration disabled unless you explicitly open it with SYNCCIT_ALLOW_REGISTRATION=true or --allow-registration. That was my call. The original left it open. A public create endpoint on a personal server is an open invitation to spam accounts, and the CLI already covers creating users. addauth stays open behind a password, because that is device provisioning, not registration.
Verification
The repo is rezhajulio/synccit-go, private for now. Tests passed, along with go vet and gofmt, and CI passed on the branch. I also ran a live smoke test with the real binary: create a user and device through the CLI, a JSON update returning “4 links updated”, read-back matching the README scenario, plaintext and XML reads, account login returning a 64-hex token, and a locked create returning registration disabled.
The client side: patching Boost
A server nobody can point a client at is useless. Boost 1.14.0 still has Synccit, but the URL is a hardcoded const-string in exactly four places in the dex, all feeding an OkHttp request with a POST body of data= plus JSON: create, addauth, read, and update. I verified that with dexdump on the release APK, whose SHA-256 matched the release listing, and whose signing certificate matched too. Covering those four sites covers all of Boost’s Synccit traffic.
The patch is a Morphe patch, one user-facing patch called “Synccit URL”, with a patch-time option for the URL. The default stays the stock URL, so an unconfigured patch behaves exactly like stock Boost. It fails closed: every fingerprint must match exactly once or the whole patch aborts instead of rewriting half the call sites.
Building it was the hard part, and it had nothing to do with the patch code. Gradle cannot run in my build environment. Its daemon talks over loopback, and loopback is broken for Java there: byte 42 arrives as 109. The daemon dies with a broken pipe, before it even gets to resolving dependencies.
The workaround was to skip Gradle entirely. Morphe Desktop 1.19.0 ships a fat jar that bundles the real patcher 2.0.0 classes, so I compiled the patch sources against that jar with kotlinc, packaged the result as a .mpp by hand, and verified it with a small driver that runs the real Patcher against the pinned APK. The first hand-built bundle worked in Morphe Desktop and showed zero patches in Morphe Manager on my phone. That took a while to diagnose, and my first diagnosis was wrong.
The root cause was that Manager loads patches from DEX, not from .class files. Desktop loads from the jar, Manager calls loadPatchesFromDex and throws when the bundle has no dex entries. My bundle shipped .class files only. The fix was to run the patch classes through Android build-tools d8 and add the resulting classes.dex to the bundle alongside the class files. The rebuilt bundle, 33,281 bytes, passed verification again, and that evening I confirmed on my phone that the patch shows up for Boost and works.
Boost 1.14.1 then made the patch partly unnecessary. Upstream refactored Synccit so the URL comes from a settings getter reading a synccit_api_url preference, falling back to the stock URL, and the app now has a real “Synccit API URL” setting. On 1.14.1 you can just type your server URL in the app. Still there in 1.14.3, checked November 5 against the official APK: same synccit_api_url key, same “Synccit API URL” setting. I built bundle 1.0.1 anyway, dual-shape: it patches the new getter on 1.14.1 and falls back to the four-site shape on 1.14.0, and it still fails closed if neither shape matches. It verified PATCH OK against both APKs.
The detour I stopped
While I was in the Boost dex I also looked at Patcheddit, a patch bundle that revives third-party Reddit clients. It targets Boost 1.12.12 only, so porting meant re-anchoring fingerprints to 1.14.0. I skipped everything Reddit-API related, and the audio fix, because 1.14.0 already fixed downloaded-video audio. I chose Disable Ads and Undelete.
Disable Ads never got built. Patcheddit’s patch just early-returns two ad-mediation init methods, and neither those methods nor any AdMob, AppLovin, Unity, or IronSource SDK exists in Boost 1.14.0. The mediation wrapper went away when Boost left Google Play. A straight port would match zero methods.
Undelete got built, as bundle 1.1.0, and then crashed Boost on launch on my phone. Every combination: Reddit only, Imgur only, both. The Synccit patch in the same bundle worked fine with the undelete patches disabled. A diagnostic bundle with only the shared extension, no intercept hooks at all, launched fine, so the fault was downstream, when the OkHttp hooks fire. I built a second diagnostic to narrow it further, and it was never tested, because on October 25 I stopped. Undelete was a side quest, and the Synccit bundle already did the job I actually needed. The intercept-stage fault is unresolved by choice.
Epilogue: JSON v2, two weeks later
On November 5, Go 1.27’s new encoding/json/v2 went stable, and I tried it on synccit-go. The migration was small: two files, the server-side JSON imports, json.RawMessage replaced with jsontext.Value because v2 has no RawMessage, and the response helper rewritten around json.Marshal with jsontext.EscapeForHTML(false). The tests stayed on v1 on purpose, so they also prove a v1 client can still read the v2 server.
I benchmarked it before merging, because the Go blog post claimed up to 10x faster unmarshaling and I did not believe that would show up here. It did not. Median of three runs, on my test machine: parsing an update request 4.1% faster, a single link unmarshal 11.4% faster, marshaling a 100-link history unchanged, marshaling a small success map 28.2% faster with 296 bytes down to 104. The handlers barely moved, 6.5% and 2.7%, and the update handler was noisy enough that I would not quote that number precisely. The payloads are tiny and the handlers are SQLite-bound. JSON was never the bottleneck.
I took v2 for the strictness. The speed is a bonus, and a small one here. Duplicate object names and invalid UTF-8 are now rejected instead of silently accepted, which is what you want from a server that takes JSON from clients you do not control. That merged to main as 3bf3149, CI green, along with a dependency bump to modernc.org/sqlite 1.61.1.
What is left
The one test I had not run was the one that mattered most: the full protocol round trip, create, addauth, read, and update, from Boost on my phone against a live synccit-go instance. On November 5 I ran it, and it works. The server is verified locally, the client is verified on the phone, and now the two together, end to end, are verified too. What is left is deciding whether to make the repo public, and whether the server earns a permanent home instead of a test box.