Skip to content

Contributing and Roadmap

Rajat edited this page Sep 9, 2026 · 2 revisions

Contributing and Roadmap

This page explains how the public roadmap board is organised, how to pick up and report a piece of work, what the code has to look like, and which kinds of change will be declined and why.

The short version of the project's attitude is in CONTRIBUTING.md: the most useful contribution is a correction to RESEARCH.md. Reproducing a claim, or showing that one is wrong, is worth more than a feature. Everything below is in service of that.

How the board is organised

The board has a single Status field with four values, and every card sits in exactly one of them.

Column What it holds How a card leaves it
Verified Findings that were reproduced against a real browser. These are draft cards that mirror sections of RESEARCH.md, not issues, because there is nothing left to do. It does not, unless a later browser version breaks the finding, in which case a new issue is opened.
Needs reproduction Claims the verification table grades as source-derived or untested. Each is one issue, each is settled by one machine running one experiment. A reproduction is reported, positive or negative; the verification table is updated either way.
Help wanted Other browsers and operating systems. The author verified on Brave and macOS only and cannot test elsewhere. Someone with the platform reports what they saw, or opens a draft PR.
Planned Enhancements that have a design written down but no code yet. A PR lands, or a Discussion concludes it is not worth building.

Three labels carry the same distinctions on the issues themselves, so a search works without the board:

  • research: a claim in RESEARCH.md that needs reproduction or correction.
  • verification-needed: marked source-derived or untested in the verification table.
  • platform: another browser or operating system.

good first issue marks the ones that need a single request or a single throwaway-profile run rather than a design conversation. Research Status gives the reasoning behind every card's placement, finding by finding.

Every card, at the time of writing

Verified (draft cards, no issue number):

  • Finding 1: bookmark checksum hashes titles as UTF-16LE
  • Finding 2: Brave reads native-messaging manifests from Chrome's directory
  • Finding 3: a packed CRX permanently poisons the extension id
  • Finding 4: Brave reads Bookmarks once at startup and re-serialises from memory
  • Finding 8: MV3 lifecycle race creates duplicate bookmarks
  • Finding 10: Safari has no supported write path

Needs reproduction:

Help wanted:

Planned:

How to pick something up

Reproductions (Needs reproduction)

These are the highest-value work on the board and the least code. Each issue contains its own step-by-step plan; what follows is what the plans have in common and why.

Read the finding first. Each issue names its RESEARCH.md section. The section explains what was believed, what actually happens, and how far the existing evidence goes, so you know what a positive and a negative result each look like before you start.

Work on a copy, never the live profile. This is the rule that makes the whole method safe, and it is not politeness: a bug in a bookmarks-file writer silently destroys a library, and Chromium has no restore path (finding 6, which is itself #2). The copy is cheap:

LAB=/tmp/lab; rm -rf "$LAB"; mkdir -p "$LAB/Default"
cp "$(python3 -c 'import json,os;print(json.load(open(os.path.expanduser("~/.raindrop-sync/config.json")))["profile"])')/Bookmarks" "$LAB/Default/"
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" \
  --user-data-dir="$LAB" --no-first-run --enable-logging=stderr --v=0

--user-data-dir makes Brave treat the copy as its whole world, so nothing you do reaches the real profile. --enable-logging=stderr --v=0 is the only practical window into extension and native-messaging plumbing; the launch_context.cc lines are where native-messaging failures explain themselves. Quit Brave from its menu when you are done rather than killing it, because the quit-time save is part of what several findings test.

Point the code at throwaway directories too. Every path the applier and the installer touch can be overridden through the environment, so the identical code runs against the lab and against production. That property is what caught a real bug before it reached a live profile (a leading Bookmarks Bar path component was being created as a folder, building a duplicate tree beside the user's real one):

Variable What it redirects Read by
RAINDROP_SYNC_PROFILE the Brave profile directory holding Bookmarks src/apply_brave.py
RAINDROP_SYNC_ROOT the state directory (normally ~/.raindrop-sync) apply_brave.py, src/native_host.py, scripts/bootstrap.sh
RAINDROP_SYNC_NMH_CHROME, RAINDROP_SYNC_NMH_BRAVE the two native-messaging manifest directories bootstrap.sh
RAINDROP_SYNC_LAUNCHAGENTS where the launchd plist is written bootstrap.sh
RAINDROP_SYNC_SKILLS where the classification skill is copied bootstrap.sh
RAINDROP_SYNC_PYTHON the interpreter baked into the plist bootstrap.sh
SKIP_LAUNCHCTL=1 write the plist without loading it bootstrap.sh

A full installer dry run that cannot touch anything real looks like this:

RAINDROP_SYNC_ROOT=/tmp/root \
RAINDROP_SYNC_NMH_CHROME=/tmp/nmh \
RAINDROP_SYNC_LAUNCHAGENTS=/tmp/agents \
SKIP_LAUNCHCTL=1 bash scripts/bootstrap.sh

and a file-writer dry run against a copied Bookmarks looks like this:

T=/tmp/applytest; rm -rf $T; mkdir -p $T/profile $T/root
cp "$(python3 -c 'import json,os;print(json.load(open(os.path.expanduser("~/.raindrop-sync/config.json")))["profile"])')/Bookmarks" $T/profile/
# stage a desired.json in $T/root, then:
RAINDROP_SYNC_PROFILE=$T/profile RAINDROP_SYNC_ROOT=$T/root python3 src/apply_brave.py

If you add a path to the code, it must join this table. See Operations for what the same variables mean in a production install.

Diff what matters, separately. After Brave reformats a large JSON file, a raw diff tells you nothing. Compare the set of GUIDs, the set of ids, the titles and the checksum on their own; "0 GUIDs vanished, 0 ids changed" is a result, a wall of reflowed JSON is not.

Run it twice. Once with the browser closed for the whole run, once with it running. The two answers differ (finding 4), and a design that only ever tested one of them is untested.

Platform work (Help wanted)

#6, #7 and #8 each say what they need reported. Two things are worth knowing before you start:

  • Findings 1, 2 and 3 are the ones most likely to differ per browser: the checksum, which NativeMessagingHosts directory is actually read, and how the install verifier treats sideloads. Report all three even if only one seems relevant.
  • The file-writer half is Chromium-specific by construction; what a Firefox port would consist of is scoped in #8. Windows and Linux need a replacement for launchd and for the macOS native-messaging registration, but the Python is stdlib-only and the extension is plain MV3, so the applier logic itself should port unchanged. Open one draft PR per operating system rather than a combined change.

Enhancements (Planned)

Each Planned issue describes a design and an obstacle. Say on the issue that you are starting, so two people do not build the same thing, and expect the obstacle to be the real work: for #9 it is MV3 killing idle workers so a long-lived port must survive reconnects; for #11 it is that widening the hash invalidates every stored hash at once, so it must ship as a versioned migration with a one-time rebase, the same way taxonomy_version works (Ledger and File Formats). If a change contradicts a documented invariant, say which one and why the reasoning no longer holds.

How to report

Open an issue. For a research correction, include the four things CONTRIBUTING.md asks for, plus the raw output. "It didn't work" cannot be acted on; a launch_context.cc log line can.

  1. Which finding, by number.
  2. What you observed instead, stated as plainly as the finding states its claim.
  3. The exact command or test that produced it. Paste the command, not a description of it. If it was the throwaway-profile method, say which variant.
  4. Versions and state. Your Brave (or Chrome) version from brave://version, your macOS version, and whether the browser was running during the write. Finding 4 is the reason the last one matters: the result depends on it.
  5. The raw output. Log lines, the two hashes, the disable_reasons value, the HTTP status code, whichever the issue asks for.

A skeleton that covers all five:

Finding: 5
Observed: Bookmarks.bak unchanged after adding one bookmark and quitting
Command:
  LAB=/tmp/lab; ... (the commands you actually ran)
Brave: 152.x.y.z    macOS: 26.x    Browser running during write: no
Output:
  before: <sha256 of Bookmarks.bak>
  after:  <sha256 of Bookmarks.bak>

A negative result is as useful as a positive one. The verification table in RESEARCH.md is updated either way, and a reproduction that fails on your machine is exactly the kind of version-dependence the table exists to record.

Never include

  • A Raindrop token, or any secret. Not even partially. A token that has reached a transcript, a scrollback or an issue must be rotated, and there is no way to un-print it. #3 and #4 both need a token to run and both say so explicitly.
  • A real bookmark. Titles and URLs from your own library are your own business. Use invented ones (https://example.com/recipes/sourdough, folder Recipes) when the report needs a concrete node.
  • Collection ids, extension ids, or absolute paths containing a username. All three are per-install. An extension id is 32 lowercase letters from a to p and derives from your own signing key, so yours identifies your install and nobody else's; write <ext-id>. Write $HOME or ~/.raindrop-sync rather than the expanded path.

If you accidentally paste any of these, edit the issue immediately and say what happened. Rotate the token regardless of how quickly the edit went in.

Rules for code

Each rule comes from a specific failure, and CONTRIBUTING.md lists them because the alternative was tried.

Python: standard library only. No dependencies is a feature, not a gap. The scripts run under the system /usr/bin/python3, which needs no venv or pip and does not move when Homebrew upgrades; the launchd plist bakes in an absolute interpreter path, and a Homebrew bin symlink that moves on brew upgrade silently breaks the agent. A dependency would need an interpreter that has it installed, which reintroduces exactly that fragility. sqlite3, hashlib, uuid, struct and json from the standard library cover everything the two scripts do.

Shell: bash -n clean, set -euo pipefail. Run bash -n scripts/bootstrap.sh before opening a PR. The strict mode is there because the installer writes into ~/Library and loads a launchd agent; an unnoticed failed step followed by a successful one is worse than a loud stop.

The extension: plain MV3, no build step. There is no bundler, no transpiler and no packaging, and there cannot be one, because Load unpacked is the only install route that works (see the CRX rule below). sw.js is the file Brave runs. Three habits follow:

  • Bump manifest.json's version whenever sw.js changes, then press reload on the extension card. Unpacked extensions do not hot-reload, and a stale worker is an afternoon of confusion.
  • Do not remove the serialize() lock in extension/sw.js. Every entry point (load, onInstalled, onStartup, onAlarm) runs through one promise chain because two of them fired in the same millisecond and produced a real duplicate bookmark (finding 8). A delay would move that race, not close it.
  • Keep the host name identical in three places: the HOST constant in sw.js, the name field in the native-messaging manifest, and the manifest's filename. A mismatch in any one is a silent "host not found".

The native host writes nothing to stdout except protocol frames. src/native_host.py speaks a 4-byte length prefix plus UTF-8 JSON on stdin and stdout. A stray print() corrupts the stream and Brave drops the connection without saying why. Every diagnostic goes to ~/.raindrop-sync/log/native_host.log through the log() helper. New code in that file follows the same rule.

Keep every path parameterised. A new file or directory must be resolvable through an environment variable with the same shape as the existing RAINDROP_SYNC_* set, so the throwaway-profile method keeps working. This is what allowed the duplicate-tree bug to be caught in a dry run rather than in a live profile.

Respect the invariants that look arbitrary. Nearly every rule in the writer and the classifier is there because a specific failure was found by testing, and the reasoning is written down in RESEARCH.md. Two that get "tidied" by well-meaning patches: the ledger's content_hash is computed over raw source values and is allowed to disagree with the curated title and link columns (recomputing one from the other re-flags a quarter of the library forever), and the file writer matches existing nodes by marker and by URL, never by position (positional lookup duplicates the moment a folder is dragged). Safety and Ownership Model and Taxonomy and Classification explain the rest. If your change contradicts one of them, the PR description should name the invariant and say why the reasoning no longer holds.

What will be declined

These are design decisions, not oversights, and each has a documented reason. Please do not spend an evening on them without first reading the reasoning.

Anything that deletes. Both appliers are additive-only and deletion propagation is off. From the outside, a bookmark that was trashed in Raindrop, or one that fell between pages during a paging race, looks exactly like a hard delete, and acting on a false delete costs the user data this tool cannot restore. The extension's resetFolder() is the one removal in the codebase, and it runs only on a folder the native host explicitly names as a direct child of the bar that the tool owns end to end; it is not a general deletion path and will not become one. Nothing that runs without confirmation will be accepted in this area.

A catch-all category. Misc, Other, Uncategorised, Noise, Junk, Various, and any catch-all wearing a friendlier name are forbidden collection names. A bookmark is a deliberate act; a classifier that shrugs and bins what it does not recognise destroys the signal it exists to find. When nothing fits, the correct outcome is a new collection. If the classifier cannot place something, that is information about a gap in the taxonomy, and the fix is in the taxonomy, not in a bin. This is the first design principle and Taxonomy and Classification explains it in full.

Safari support. Seven independent avenues are closed, each one checked rather than assumed, and the direct-write route is guarded against an iCloud-backed store that would fan a write out to every Apple device on the account. The details are in RESEARCH.md finding 10 and in Research Status. The only thing that will be built is the manual escape hatch in #10: an exported HTML file the user imports by hand, documented as an occasional deliberate action.

Packing a .crx. A locally signed CRX installs, is immediately disabled with DISABLE_NOT_VERIFIED, and the flag attaches to the extension id rather than the installation, so even reloading the same folder unpacked afterwards stays disabled. The only escape is a new id, which means a new key and new allowed_origins in every host manifest. The first identity minted for this project was lost exactly this way. Nothing that adds a packaging step, a store listing, or an external-extension registration will be merged.

The README's "Deliberately not included" list also names a bundled taxonomy, a cloud component and telemetry. Those are the same list seen from the other side: a shipped taxonomy would be somebody else's interests imposed on your bookmarks, and the whole point of local state is that nothing leaves the machine except calls to the Raindrop API. Start a Discussion before writing code for any of them.

Before you open the pull request

  • You read the RESEARCH.md section your change touches.
  • You tested against a copy with RAINDROP_SYNC_PROFILE and RAINDROP_SYNC_ROOT pointed at throwaway directories, once with Brave closed and once with it running.
  • bash -n passes on any shell you touched; any Python you touched imports nothing outside the standard library.
  • If sw.js changed, manifest.json's version changed with it.
  • The diff, the description and any log excerpt contain no token, no real bookmark, no collection id, no extension id, and no absolute path with a username in it.

Questions about scope, or about whether something belongs on the board at all, go to Discussions. Something you cannot get working goes to Troubleshooting first, then to an issue with the raw output attached.

Clone this wiki locally