Files
sovereign_browser/src/settings_sync.c
T

825 lines
33 KiB
C

/*
* settings_sync.c — NIP-78 (kind 30078) settings sync for sovereign_browser
*
* Syncs whitelisted, device-independent settings + keyboard shortcut
* bindings across all devices the user logs in on. Uses a single shared
* kind 30078 addressable event with d-tag "user-settings" (the same event
* used by ~/lt/client and ~/lt/didactyl). The content is NIP-44 encrypted
* (self-to-self) JSON with a "global" namespace (shared agent provider
* catalog under global.agent) and per-app namespaces (sovereign_browser,
* client, didactyl, ...).
*
* sovereign_browser writes only the "sovereign_browser" and "global.agent"
* namespaces. Other apps' namespaces are preserved via read-modify-write:
* on publish, the current d:user-settings event is fetched from the SQLite
* cache (or relays), decrypted, the sovereign_browser + global.agent
* namespaces are patched, and the result is re-encrypted and published.
*
* The publish path reuses the same pattern as bookmarks.c's
* publish_directory(): serialize → NIP-44 encrypt → sign → store in
* SQLite → publish to bootstrap relays.
*
* settings_sync_publish() is debounced (500ms) so rapid edits coalesce.
*
* Schema (v2):
* {
* "v": 2,
* "updatedAt": <ts>,
* "global": {
* "agent": {
* "providers": [ {name, base_url, api_key, models}, ... ],
* "favorites": [ "model-id", ... ]
* },
* ... (zaps, ui, relays, ... — preserved, not touched by us)
* },
* "sovereign_browser": {
* "agent": { provider, model, system_prompt, max_iterations },
* "new_tab_url": "", "tab_bar_position": 0, "max_tabs": 50,
* "bootstrap_relays": "...", "search_engine": "duckduckgo",
* "theme_dark": false, "shortcuts": { ... }
* },
* "client": { ... }, // preserved
* "didactyl": { ... } // preserved
* }
*/
#include "settings_sync.h"
#include "settings.h"
#include "shortcuts.h"
#include "db.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <time.h>
#include "nostr_core/nostr_core.h"
#include "../nostr_core_lib/cjson/cJSON.h"
/* ── Global state ───────────────────────────────────────────────────── */
static nostr_signer_t *g_signer = NULL;
static char g_pubkey[65] = {0};
static int g_have_signer = 0;
/* Debounce: a timeout source id for the deferred publish. */
static guint g_publish_timeout_id = 0;
/* db_kv key for the last-synced timestamp. */
#define SYNC_TS_KEY "settings_sync.nostr_synced_at"
/* ── Whitelist of syncable browser settings ─────────────────────────── */
/* Keys from the settings struct that should be synced under the
* sovereign_browser namespace. Device-specific settings (agent port,
* allowed origins, session restore, security toggles) are intentionally
* excluded. */
static const char *g_sync_setting_keys[] = {
"new_tab_url",
"tab_bar_position",
"show_tab_close_buttons",
"middle_click_close",
"ctrl_tab_switch", /* master shortcuts toggle */
"tab_drag_reorder",
"max_tabs",
"bootstrap_relays",
"search_engine",
};
static const int g_sync_setting_count =
(int)(sizeof(g_sync_setting_keys) / sizeof(g_sync_setting_keys[0]));
/* ── Helpers ────────────────────────────────────────────────────────── */
/* Parse bootstrap relays from settings into an array.
* Returns the count. Fills urls_out (pointers into relay_buf).
* Caller must g_free(relay_buf) after use. */
static int parse_relays(char **relay_buf, const char **urls_out, int max) {
const browser_settings_t *s = settings_get();
*relay_buf = g_strdup(s->bootstrap_relays);
int count = 0;
char *saveptr = NULL;
char *line = strtok_r(*relay_buf, "\n\r", &saveptr);
while (line != NULL && count < max) {
while (*line == ' ' || *line == '\t') line++;
if (line[0] != '\0' &&
(strncmp(line, "wss://", 6) == 0 ||
strncmp(line, "ws://", 5) == 0)) {
urls_out[count++] = line;
}
line = strtok_r(NULL, "\n\r", &saveptr);
}
return count;
}
/* Encrypt a plaintext string with NIP-44 (self-to-self).
* Returns a newly allocated string (caller must free), or NULL on error. */
static char *encrypt_content(const char *plaintext) {
if (!g_have_signer || g_pubkey[0] == '\0') return NULL;
char *ciphertext = NULL;
int rc = nostr_signer_nip44_encrypt(g_signer, g_pubkey, plaintext,
&ciphertext);
if (rc != NOSTR_SUCCESS || ciphertext == NULL) {
g_printerr("[settings_sync] NIP-44 encrypt failed: %d\n", rc);
return NULL;
}
return ciphertext;
}
/* Decrypt a NIP-44 ciphertext (self-to-self).
* Returns a newly allocated string (caller must free), or NULL on error. */
static char *decrypt_content(const char *ciphertext) {
if (!g_have_signer || g_pubkey[0] == '\0') return NULL;
if (ciphertext == NULL || ciphertext[0] == '\0') return NULL;
char *plaintext = NULL;
int rc = nostr_signer_nip44_decrypt(g_signer, g_pubkey, ciphertext,
&plaintext);
if (rc != NOSTR_SUCCESS || plaintext == NULL) {
g_printerr("[settings_sync] NIP-44 decrypt failed: %d\n", rc);
return NULL;
}
return plaintext;
}
/* Check whether a kind 30078 event has a given d-tag value.
* Returns 1 if matched, 0 otherwise. */
static int event_has_d_tag(const cJSON *event, const char *d_value) {
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!cJSON_IsArray(tags)) return 0;
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(t0) && strcmp(t0->valuestring, "d") == 0 &&
cJSON_IsString(t1) && strcmp(t1->valuestring, d_value) == 0) {
return 1;
}
}
return 0;
}
/* ── Serialization: build sovereign_browser namespace ───────────────── */
/* Build the "sovereign_browser" namespace object from current settings.
* Returns a newly allocated cJSON object (caller must delete). */
static cJSON *build_sovereign_browser_namespace(void) {
cJSON *sb = cJSON_CreateObject();
/* Browser settings whitelist (read via db_kv_get for uniform string
* values — same approach as the original serialize_payload). */
for (int i = 0; i < g_sync_setting_count; i++) {
const char *key = g_sync_setting_keys[i];
const char *val = db_kv_get(key);
if (val) {
cJSON_AddStringToObject(sb, key, val);
}
}
/* Per-app agent config: provider, model, system_prompt (legacy),
* max_iterations, and the Sovereign Browser Skill fields. These are
* stored in db_kv under the agent.* keys by handle_agents_set. */
cJSON *agent = cJSON_CreateObject();
const char *provider = db_kv_get("agent.provider");
const char *model = db_kv_get("agent.llm_model");
const char *prompt = db_kv_get("agent.llm_system_prompt");
const char *maxit = db_kv_get("agent.max_iterations");
const char *sk_name = db_kv_get("agent.skill_name");
const char *sk_desc = db_kv_get("agent.skill_description");
const char *sk_tmpl = db_kv_get("agent.skill_template");
const char *sk_tools = db_kv_get("agent.skill_requires_tools");
cJSON_AddStringToObject(agent, "provider",
provider ? provider : "");
cJSON_AddStringToObject(agent, "model",
model ? model : "");
cJSON_AddStringToObject(agent, "system_prompt",
prompt ? prompt : "");
cJSON_AddNumberToObject(agent, "max_iterations",
maxit ? (double)atol(maxit)
: (double)SETTINGS_AGENT_MAX_ITERATIONS_DEFAULT);
cJSON_AddStringToObject(agent, "skill_name",
sk_name ? sk_name : "");
cJSON_AddStringToObject(agent, "skill_description",
sk_desc ? sk_desc : "");
cJSON_AddStringToObject(agent, "skill_template",
sk_tmpl ? sk_tmpl : "");
cJSON_AddStringToObject(agent, "skill_requires_tools",
sk_tools ? sk_tools : "");
cJSON_AddItemToObject(sb, "agent", agent);
/* Shortcuts. */
cJSON *shortcuts_obj = shortcuts_serialize();
cJSON_AddItemToObject(sb, "shortcuts", shortcuts_obj);
return sb;
}
/* Build the "global.agent" namespace object from the in-memory provider
* catalog. Returns a newly allocated cJSON object (caller must delete). */
static cJSON *build_global_agent_namespace(void) {
cJSON *agent = cJSON_CreateObject();
const browser_settings_t *s = settings_get();
/* providers array */
cJSON *providers = cJSON_CreateArray();
for (int i = 0; i < s->agent_provider_count; i++) {
const agent_provider_t *p = &s->agent_providers[i];
cJSON *pobj = cJSON_CreateObject();
cJSON_AddStringToObject(pobj, "name", p->name);
cJSON_AddStringToObject(pobj, "base_url", p->base_url);
cJSON_AddStringToObject(pobj, "api_key", p->api_key);
cJSON *models = cJSON_CreateArray();
for (int m = 0; m < p->model_count; m++) {
cJSON_AddItemToArray(models, cJSON_CreateString(p->models[m]));
}
cJSON_AddItemToObject(pobj, "models", models);
cJSON_AddItemToArray(providers, pobj);
}
cJSON_AddItemToObject(agent, "providers", providers);
/* favorites — for now, just the currently selected model. */
cJSON *favorites = cJSON_CreateArray();
if (s->agent_llm_model[0]) {
cJSON_AddItemToArray(favorites, cJSON_CreateString(s->agent_llm_model));
}
cJSON_AddItemToObject(agent, "favorites", favorites);
return agent;
}
/* ── Read-modify-write: fetch current event, patch, return payload ──── */
/* Fetch the current d:user-settings event from the SQLite cache.
* Returns a newly allocated cJSON event (caller must delete), or NULL. */
static cJSON *fetch_current_user_settings_event(void) {
if (g_pubkey[0] == '\0') return NULL;
/* db_get_latest_event returns the newest event of a given kind for
* the pubkey. We then verify the d-tag is "user-settings". */
cJSON *events = db_get_events(g_pubkey, SETTINGS_SYNC_KIND, 32);
if (events == NULL) return NULL;
int n = cJSON_GetArraySize(events);
cJSON *found = NULL;
for (int i = 0; i < n; i++) {
cJSON *ev = cJSON_GetArrayItem(events, i);
if (event_has_d_tag(ev, SETTINGS_SYNC_D_TAG)) {
found = cJSON_Duplicate(ev, 1);
break;
}
}
cJSON_Delete(events);
return found;
}
/* Decrypt the content of an event and parse it as a JSON object.
* Returns a newly allocated cJSON object (caller must delete), or NULL. */
static cJSON *decrypt_event_payload(const cJSON *event) {
cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content");
if (!cJSON_IsString(content)) return NULL;
char *plaintext = decrypt_content(content->valuestring);
if (plaintext == NULL) return NULL;
cJSON *payload = cJSON_Parse(plaintext);
free(plaintext);
return payload;
}
/* Build the full payload to publish, using read-modify-write:
* 1. Fetch the current d:user-settings event from SQLite cache.
* 2. Decrypt + parse it (or start a fresh v2 object if none).
* 3. Patch the "sovereign_browser" and "global.agent" namespaces.
* 4. Return the patched payload (caller must delete).
*
* Other namespaces (client, didactyl, global.zaps, global.ui, ...) are
* preserved untouched. */
static cJSON *build_publish_payload(void) {
cJSON *payload = NULL;
cJSON *current = fetch_current_user_settings_event();
if (current != NULL) {
payload = decrypt_event_payload(current);
cJSON_Delete(current);
}
if (payload == NULL || !cJSON_IsObject(payload)) {
/* No existing event (or decrypt failed) — start fresh. */
if (payload) cJSON_Delete(payload);
payload = cJSON_CreateObject();
cJSON_AddNumberToObject(payload, "v", 2);
}
/* Ensure "global" object exists. */
cJSON *global = cJSON_GetObjectItemCaseSensitive(payload, "global");
if (!cJSON_IsObject(global)) {
global = cJSON_CreateObject();
cJSON_AddItemToObject(payload, "global", global);
}
/* Patch global.agent (replace entirely with our view). */
cJSON *old_agent = cJSON_GetObjectItemCaseSensitive(global, "agent");
if (old_agent) cJSON_DeleteItemFromObjectCaseSensitive(global, "agent");
cJSON *new_agent = build_global_agent_namespace();
cJSON_AddItemToObject(global, "agent", new_agent);
/* Patch sovereign_browser namespace (replace entirely). */
cJSON *old_sb = cJSON_GetObjectItemCaseSensitive(payload, "sovereign_browser");
if (old_sb) cJSON_DeleteItemFromObjectCaseSensitive(payload, "sovereign_browser");
cJSON *new_sb = build_sovereign_browser_namespace();
cJSON_AddItemToObject(payload, "sovereign_browser", new_sb);
/* Update top-level updatedAt. */
cJSON *ts = cJSON_GetObjectItemCaseSensitive(payload, "updatedAt");
if (ts) cJSON_DeleteItemFromObjectCaseSensitive(payload, "updatedAt");
cJSON_AddNumberToObject(payload, "updatedAt", (double)time(NULL));
return payload;
}
/* ── Publish (debounced) ────────────────────────────────────────────── */
/* The actual publish operation (no debounce). */
static void do_publish(void) {
if (!g_have_signer) return;
/* Build the payload via read-modify-write. */
cJSON *payload = build_publish_payload();
if (payload == NULL) {
g_printerr("[settings_sync] Failed to build payload\n");
return;
}
char *json = cJSON_PrintUnformatted(payload);
cJSON_Delete(payload);
if (json == NULL) {
g_printerr("[settings_sync] Failed to serialize payload\n");
return;
}
/* Encrypt. */
char *ciphertext = encrypt_content(json);
free(json);
if (ciphertext == NULL) return;
/* Build tags: [["d", "user-settings"], ["client", "sovereign_browser"]] */
cJSON *tags = cJSON_CreateArray();
cJSON *d_tag = cJSON_CreateArray();
cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
cJSON_AddItemToArray(d_tag, cJSON_CreateString(SETTINGS_SYNC_D_TAG));
cJSON_AddItemToArray(tags, d_tag);
cJSON *client_tag = cJSON_CreateArray();
cJSON_AddItemToArray(client_tag, cJSON_CreateString("client"));
cJSON_AddItemToArray(client_tag, cJSON_CreateString("sovereign_browser"));
cJSON_AddItemToArray(tags, client_tag);
/* Create and sign the event. */
cJSON *event = nostr_create_and_sign_event_with_signer(
SETTINGS_SYNC_KIND, ciphertext, tags, g_signer, time(NULL));
g_free(ciphertext);
if (event == NULL) {
g_printerr("[settings_sync] Failed to create/sign event\n");
cJSON_Delete(tags);
return;
}
/* Store in SQLite. */
db_store_event(event);
/* Record the sync timestamp. */
char ts_buf[32];
snprintf(ts_buf, sizeof(ts_buf), "%ld", (long)time(NULL));
db_kv_set(SYNC_TS_KEY, ts_buf);
/* Publish to bootstrap relays. */
char *relay_buf = NULL;
const char *relay_urls[32];
int relay_count = parse_relays(&relay_buf, relay_urls, 32);
if (relay_count > 0) {
int success_count = 0;
publish_result_t *results = synchronous_publish_event_with_progress(
relay_urls, relay_count, event, &success_count,
15, NULL, NULL, 0, NULL);
if (results) free(results);
g_print("[settings_sync] Published kind %d (d:%s) to %d/%d relays\n",
SETTINGS_SYNC_KIND, SETTINGS_SYNC_D_TAG,
success_count, relay_count);
} else {
g_print("[settings_sync] No relays configured, event stored locally\n");
}
g_free(relay_buf);
cJSON_Delete(event);
}
/* GLib timeout callback for the debounced publish. */
static gboolean publish_timeout_cb(gpointer data) {
(void)data;
g_publish_timeout_id = 0;
do_publish();
return G_SOURCE_REMOVE;
}
/* ── Merge helpers ──────────────────────────────────────────────────── */
/* Merge the "sovereign_browser" namespace from a decrypted payload into
* local db_kv + in-memory settings. */
static void merge_sovereign_browser_namespace(const cJSON *sb) {
if (!cJSON_IsObject(sb)) return;
/* Browser settings whitelist. */
cJSON *item;
cJSON_ArrayForEach(item, sb) {
if (!cJSON_IsString(item)) continue;
for (int i = 0; i < g_sync_setting_count; i++) {
if (strcmp(item->string, g_sync_setting_keys[i]) == 0) {
db_kv_set(item->string, item->valuestring);
break;
}
}
}
/* Per-app agent config. Only overwrite local values from Nostr if
* the local value is empty — this prevents a corrupted Nostr event
* from overwriting good local data. The local DB is the source of
* truth for per-app settings; Nostr is only used to bootstrap new
* devices. */
cJSON *agent = cJSON_GetObjectItemCaseSensitive(sb, "agent");
if (cJSON_IsObject(agent)) {
cJSON *j;
const char *existing;
j = cJSON_GetObjectItemCaseSensitive(agent, "provider");
if (cJSON_IsString(j) && j->valuestring[0]) {
existing = db_kv_get("agent.provider");
if (!existing || !existing[0])
db_kv_set("agent.provider", j->valuestring);
}
j = cJSON_GetObjectItemCaseSensitive(agent, "model");
if (cJSON_IsString(j) && j->valuestring[0]) {
existing = db_kv_get("agent.llm_model");
if (!existing || !existing[0])
db_kv_set("agent.llm_model", j->valuestring);
}
j = cJSON_GetObjectItemCaseSensitive(agent, "system_prompt");
if (cJSON_IsString(j) && j->valuestring[0]) {
existing = db_kv_get("agent.llm_system_prompt");
if (!existing || !existing[0])
db_kv_set("agent.llm_system_prompt", j->valuestring);
}
j = cJSON_GetObjectItemCaseSensitive(agent, "max_iterations");
if (cJSON_IsNumber(j) && (int)j->valuedouble > 0) {
existing = db_kv_get("agent.max_iterations");
if (!existing || !existing[0] || atoi(existing) <= 0) {
char buf[16];
snprintf(buf, sizeof(buf), "%d", (int)j->valuedouble);
db_kv_set("agent.max_iterations", buf);
}
}
j = cJSON_GetObjectItemCaseSensitive(agent, "skill_name");
if (cJSON_IsString(j) && j->valuestring[0]) {
existing = db_kv_get("agent.skill_name");
if (!existing || !existing[0])
db_kv_set("agent.skill_name", j->valuestring);
}
j = cJSON_GetObjectItemCaseSensitive(agent, "skill_description");
if (cJSON_IsString(j) && j->valuestring[0]) {
existing = db_kv_get("agent.skill_description");
if (!existing || !existing[0])
db_kv_set("agent.skill_description", j->valuestring);
}
j = cJSON_GetObjectItemCaseSensitive(agent, "skill_template");
if (cJSON_IsString(j) && j->valuestring[0]) {
existing = db_kv_get("agent.skill_template");
if (!existing || !existing[0])
db_kv_set("agent.skill_template", j->valuestring);
}
j = cJSON_GetObjectItemCaseSensitive(agent, "skill_requires_tools");
if (cJSON_IsString(j) && j->valuestring[0]) {
existing = db_kv_get("agent.skill_requires_tools");
if (!existing || !existing[0])
db_kv_set("agent.skill_requires_tools", j->valuestring);
}
}
/* Shortcuts. */
cJSON *shortcuts_obj = cJSON_GetObjectItemCaseSensitive(sb, "shortcuts");
if (cJSON_IsObject(shortcuts_obj)) {
shortcuts_merge_from_json(shortcuts_obj);
}
/* Reload settings from db_kv into the in-memory singleton. */
settings_load();
}
/* Merge the "global.agent" namespace from a decrypted payload into the
* in-memory provider catalog + resolved agent_llm_* fields. */
static void merge_global_agent_namespace(const cJSON *agent_obj) {
if (!cJSON_IsObject(agent_obj)) return;
browser_settings_t *s = settings_get_mutable();
/* providers array — only merge from Nostr if the local provider
* catalog is empty. This prevents a corrupted Nostr event from
* overwriting good local provider data. The local DB is the source
* of truth; Nostr is only used to bootstrap new devices. */
cJSON *providers = cJSON_GetObjectItemCaseSensitive(agent_obj, "providers");
if (cJSON_IsArray(providers) && s->agent_provider_count == 0) {
s->agent_provider_count = 0;
int n = cJSON_GetArraySize(providers);
if (n > SETTINGS_AGENT_MAX_PROVIDERS) n = SETTINGS_AGENT_MAX_PROVIDERS;
for (int i = 0; i < n; i++) {
cJSON *p = cJSON_GetArrayItem(providers, i);
if (!cJSON_IsObject(p)) continue;
agent_provider_t *dst = &s->agent_providers[i];
memset(dst, 0, sizeof(*dst));
cJSON *j;
j = cJSON_GetObjectItemCaseSensitive(p, "name");
if (cJSON_IsString(j)) {
snprintf(dst->name, sizeof(dst->name), "%s", j->valuestring);
}
j = cJSON_GetObjectItemCaseSensitive(p, "base_url");
if (cJSON_IsString(j)) {
snprintf(dst->base_url, sizeof(dst->base_url), "%s",
j->valuestring);
}
j = cJSON_GetObjectItemCaseSensitive(p, "api_key");
if (cJSON_IsString(j)) {
snprintf(dst->api_key, sizeof(dst->api_key), "%s",
j->valuestring);
}
cJSON *models = cJSON_GetObjectItemCaseSensitive(p, "models");
if (cJSON_IsArray(models)) {
int mn = cJSON_GetArraySize(models);
if (mn > SETTINGS_AGENT_MAX_MODELS) mn = SETTINGS_AGENT_MAX_MODELS;
for (int m = 0; m < mn; m++) {
cJSON *mj = cJSON_GetArrayItem(models, m);
if (cJSON_IsString(mj)) {
snprintf(dst->models[m], sizeof(dst->models[m]),
"%s", mj->valuestring);
dst->model_count++;
}
}
}
s->agent_provider_count++;
}
}
/* Resolve the active provider from agent.provider (in db_kv or the
* sovereign_browser namespace) and populate the resolved
* agent_llm_base_url / agent_llm_api_key fields. */
const char *provider_name = db_kv_get("agent.provider");
s->agent_active_provider = -1;
s->agent_active_provider_name[0] = '\0';
if (provider_name && provider_name[0]) {
for (int i = 0; i < s->agent_provider_count; i++) {
if (strcmp(s->agent_providers[i].name, provider_name) == 0) {
s->agent_active_provider = i;
snprintf(s->agent_active_provider_name,
sizeof(s->agent_active_provider_name), "%s",
provider_name);
snprintf(s->agent_llm_base_url, sizeof(s->agent_llm_base_url),
"%s", s->agent_providers[i].base_url);
snprintf(s->agent_llm_api_key, sizeof(s->agent_llm_api_key),
"%s", s->agent_providers[i].api_key);
break;
}
}
}
/* If no active provider was matched but we have providers, fall back
* to the first provider so the resolved base_url/api_key are usable. */
if (s->agent_active_provider < 0 && s->agent_provider_count > 0) {
s->agent_active_provider = 0;
snprintf(s->agent_active_provider_name,
sizeof(s->agent_active_provider_name), "%s",
s->agent_providers[0].name);
snprintf(s->agent_llm_base_url, sizeof(s->agent_llm_base_url),
"%s", s->agent_providers[0].base_url);
snprintf(s->agent_llm_api_key, sizeof(s->agent_llm_api_key),
"%s", s->agent_providers[0].api_key);
}
/* The model comes from the per-app sovereign_browser.agent.model
* (already in db_kv via merge_sovereign_browser_namespace, or from
* the local settings). settings_load() will pick it up. */
}
/* Migrate the old d:sovereign_browser event format (top-level "settings"
* and "shortcuts" keys, no "sovereign_browser" namespace) into the new
* v2 schema. Returns a newly allocated cJSON payload (caller must delete),
* or NULL if the payload is not the old format. */
static cJSON *migrate_legacy_payload(const cJSON *payload) {
if (payload == NULL || !cJSON_IsObject(payload)) return NULL;
/* The old format has a top-level "settings" object and no
* "sovereign_browser" namespace. */
cJSON *old_settings = cJSON_GetObjectItemCaseSensitive(payload, "settings");
cJSON *new_sb = cJSON_GetObjectItemCaseSensitive(payload, "sovereign_browser");
if (!cJSON_IsObject(old_settings) || cJSON_IsObject(new_sb)) {
return NULL; /* not the old format */
}
/* Build a new v2 payload. */
cJSON *v2 = cJSON_CreateObject();
cJSON_AddNumberToObject(v2, "v", 2);
cJSON_AddNumberToObject(v2, "updatedAt", (double)time(NULL));
/* global.agent — seed from the old agent.* keys if present. */
cJSON *global = cJSON_CreateObject();
cJSON *agent = cJSON_CreateObject();
cJSON *providers = cJSON_CreateArray();
/* Build a single provider from the old agent settings. */
const browser_settings_t *s = settings_get();
cJSON *pobj = cJSON_CreateObject();
cJSON_AddStringToObject(pobj, "name", "default");
cJSON_AddStringToObject(pobj, "base_url", s->agent_llm_base_url);
cJSON_AddStringToObject(pobj, "api_key", s->agent_llm_api_key);
cJSON *models = cJSON_CreateArray();
if (s->agent_llm_model[0]) {
cJSON_AddItemToArray(models, cJSON_CreateString(s->agent_llm_model));
}
cJSON_AddItemToObject(pobj, "models", models);
cJSON_AddItemToArray(providers, pobj);
cJSON_AddItemToObject(agent, "providers", providers);
cJSON *favorites = cJSON_CreateArray();
if (s->agent_llm_model[0]) {
cJSON_AddItemToArray(favorites, cJSON_CreateString(s->agent_llm_model));
}
cJSON_AddItemToObject(agent, "favorites", favorites);
cJSON_AddItemToObject(global, "agent", agent);
cJSON_AddItemToObject(v2, "global", global);
/* sovereign_browser namespace — copy old settings + shortcuts. */
cJSON *sb_ns = cJSON_Duplicate(old_settings, 1);
cJSON *old_shortcuts = cJSON_GetObjectItemCaseSensitive(payload, "shortcuts");
if (cJSON_IsObject(old_shortcuts)) {
cJSON *sc_copy = cJSON_Duplicate(old_shortcuts, 1);
cJSON_AddItemToObject(sb_ns, "shortcuts", sc_copy);
}
/* Add per-app agent config from the old resolved fields. */
cJSON *sb_agent = cJSON_CreateObject();
cJSON_AddStringToObject(sb_agent, "provider", "default");
cJSON_AddStringToObject(sb_agent, "model", s->agent_llm_model);
cJSON_AddStringToObject(sb_agent, "system_prompt", s->agent_llm_system_prompt);
cJSON_AddNumberToObject(sb_agent, "max_iterations",
(double)s->agent_max_iterations);
cJSON_AddStringToObject(sb_agent, "skill_name", s->agent_skill_name);
cJSON_AddStringToObject(sb_agent, "skill_description",
s->agent_skill_description);
cJSON_AddStringToObject(sb_agent, "skill_template", s->agent_skill_template);
cJSON_AddStringToObject(sb_agent, "skill_requires_tools",
s->agent_skill_requires_tools);
cJSON_AddItemToObject(sb_ns, "agent", sb_agent);
cJSON_AddItemToObject(v2, "sovereign_browser", sb_ns);
g_print("[settings_sync] Migrated legacy d:%s payload to v2 schema\n",
SETTINGS_SYNC_D_TAG_LEGACY);
return v2;
}
/* ── Public API ─────────────────────────────────────────────────────── */
void settings_sync_init(nostr_signer_t *signer, const char *pubkey_hex) {
g_signer = signer;
g_have_signer = (signer != NULL);
if (pubkey_hex) {
snprintf(g_pubkey, sizeof(g_pubkey), "%s", pubkey_hex);
} else {
g_pubkey[0] = '\0';
}
}
void settings_sync_set_signer(nostr_signer_t *signer, const char *pubkey_hex) {
settings_sync_init(signer, pubkey_hex);
}
void settings_sync_publish(void) {
if (!g_have_signer) return;
/* Cancel any pending publish and schedule a new one in 500ms. */
if (g_publish_timeout_id > 0) {
g_source_remove(g_publish_timeout_id);
}
g_publish_timeout_id = g_timeout_add(500, publish_timeout_cb, NULL);
}
int settings_sync_merge_from_nostr(const void *event_cjson) {
const cJSON *event = (const cJSON *)event_cjson;
if (event == NULL) return -1;
if (!g_have_signer) return -1;
/* Verify the kind. */
cJSON *kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
if (!cJSON_IsNumber(kind) || (long)kind->valuedouble != SETTINGS_SYNC_KIND) {
return -1;
}
/* Verify the d-tag is "user-settings" (new) or "sovereign_browser"
* (legacy — will be migrated). */
int is_new = event_has_d_tag(event, SETTINGS_SYNC_D_TAG);
int is_legacy = event_has_d_tag(event, SETTINGS_SYNC_D_TAG_LEGACY);
if (!is_new && !is_legacy) return -1;
/* Get the event's created_at. */
cJSON *created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
if (!cJSON_IsNumber(created_at)) return -1;
long event_ts = (long)created_at->valuedouble;
/* Compare to our last-synced timestamp. We only skip the merge if the
* Nostr event is STRICTLY OLDER than our last sync — this prevents
* rolling back to stale data. If the timestamps are equal, we still
* merge because the local DB might be missing fields that are in the
* Nostr event (e.g., the API key was saved from another device and
* the local DB was created from a partial sync with the same timestamp). */
const char *ts_str = db_kv_get(SYNC_TS_KEY);
long local_ts = 0;
if (ts_str) local_ts = atol(ts_str);
if (event_ts < local_ts) {
g_print("[settings_sync] Nostr event (%ld) older than local (%ld), "
"skipping merge\n", event_ts, local_ts);
return 0;
}
/* Decrypt the content. */
cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content");
if (!cJSON_IsString(content)) return -1;
char *plaintext = decrypt_content(content->valuestring);
if (plaintext == NULL) return -1;
/* Parse the JSON payload. */
cJSON *payload = cJSON_Parse(plaintext);
free(plaintext);
if (payload == NULL || !cJSON_IsObject(payload)) {
g_printerr("[settings_sync] Failed to parse decrypted payload\n");
if (payload) cJSON_Delete(payload);
return -1;
}
/* If this is a legacy event, migrate the payload to v2. */
if (is_legacy) {
cJSON *migrated = migrate_legacy_payload(payload);
if (migrated) {
cJSON_Delete(payload);
payload = migrated;
}
}
/* Merge global.agent (provider catalog). */
cJSON *global = cJSON_GetObjectItemCaseSensitive(payload, "global");
if (cJSON_IsObject(global)) {
cJSON *agent_obj = cJSON_GetObjectItemCaseSensitive(global, "agent");
if (cJSON_IsObject(agent_obj)) {
merge_global_agent_namespace(agent_obj);
}
}
/* Merge sovereign_browser namespace. */
cJSON *sb = cJSON_GetObjectItemCaseSensitive(payload, "sovereign_browser");
if (cJSON_IsObject(sb)) {
merge_sovereign_browser_namespace(sb);
} else if (is_legacy) {
/* Legacy payload that wasn't migrated (migrate_legacy_payload
* returned NULL because it didn't have the old "settings" key).
* Fall back to merging top-level "settings" + "shortcuts" as the
* old code did. */
cJSON *old_settings = cJSON_GetObjectItemCaseSensitive(payload, "settings");
if (cJSON_IsObject(old_settings)) {
cJSON *item;
cJSON_ArrayForEach(item, old_settings) {
if (!cJSON_IsString(item)) continue;
for (int i = 0; i < g_sync_setting_count; i++) {
if (strcmp(item->string, g_sync_setting_keys[i]) == 0) {
db_kv_set(item->string, item->valuestring);
break;
}
}
}
settings_load();
}
cJSON *old_shortcuts = cJSON_GetObjectItemCaseSensitive(payload, "shortcuts");
if (cJSON_IsObject(old_shortcuts)) {
shortcuts_merge_from_json(old_shortcuts);
}
}
/* Update the sync timestamp. */
char ts_buf[32];
snprintf(ts_buf, sizeof(ts_buf), "%ld", event_ts);
db_kv_set(SYNC_TS_KEY, ts_buf);
g_print("[settings_sync] Merged settings from Nostr event (d:%s, ts=%ld)\n",
is_legacy ? SETTINGS_SYNC_D_TAG_LEGACY : SETTINGS_SYNC_D_TAG,
event_ts);
cJSON_Delete(payload);
return 0;
}