/* * 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 kind * 30078 addressable event with d-tag "sovereign_browser". The content is * NIP-44 encrypted (self-to-self) JSON. * * 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. */ #include "settings_sync.h" #include "settings.h" #include "shortcuts.h" #include "db.h" #include #include #include #include #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 settings ─────────────────────────────────── */ /* Keys from the settings struct that should be synced. 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; } /* ── Serialization ──────────────────────────────────────────────────── */ /* Serialize all syncable settings + shortcuts to a JSON object: * {"settings":{...}, "shortcuts":{...}} * Returns a newly allocated cJSON object (caller must delete). */ static cJSON *serialize_payload(void) { cJSON *root = cJSON_CreateObject(); /* Settings whitelist. */ cJSON *settings_obj = cJSON_CreateObject(); const browser_settings_t *s = settings_get(); (void)s; /* read via db_kv_get to get string values uniformly */ 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(settings_obj, key, val); } } cJSON_AddItemToObject(root, "settings", settings_obj); /* Shortcuts. */ cJSON *shortcuts_obj = shortcuts_serialize(); cJSON_AddItemToObject(root, "shortcuts", shortcuts_obj); return root; } /* ── Publish (debounced) ────────────────────────────────────────────── */ /* The actual publish operation (no debounce). */ static void do_publish(void) { if (!g_have_signer) return; /* Serialize. */ cJSON *payload = serialize_payload(); 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", "sovereign_browser"], ["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 to %d/%d relays\n", SETTINGS_SYNC_KIND, 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; } /* ── 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 "sovereign_browser". */ cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags"); if (!cJSON_IsArray(tags)) return -1; int is_ours = 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, SETTINGS_SYNC_D_TAG) == 0) { is_ours = 1; break; } } if (!is_ours) 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. */ 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) not newer 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; } /* Merge settings. */ cJSON *settings_obj = cJSON_GetObjectItemCaseSensitive(payload, "settings"); if (cJSON_IsObject(settings_obj)) { cJSON *item; cJSON_ArrayForEach(item, settings_obj) { if (!cJSON_IsString(item)) continue; /* Only merge whitelisted keys. */ 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; } } } /* Reload settings from db_kv into the in-memory singleton. */ settings_load(); } /* Merge shortcuts. */ cJSON *shortcuts_obj = cJSON_GetObjectItemCaseSensitive(payload, "shortcuts"); if (cJSON_IsObject(shortcuts_obj)) { shortcuts_merge_from_json(shortcuts_obj); } /* 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 (ts=%ld)\n", event_ts); cJSON_Delete(payload); return 0; }