🏡 index : quine-global-labs/mozsync.git

package main

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/ecdh"
	"crypto/hmac"
	"crypto/md5"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/mappu/miqt/qt6"
	"github.com/mappu/miqt/qt6/webengine"
)

// Borrows Firefox for Android's public OAuth client id, unlike Firefox
// Desktop's client which only works inside browser chrome.
//
// This client's registered redirect_uri used to be a real hosted page at
// lockbox.firefox.com that kept the auth code visible in the address bar.
// Mozilla discontinued the Lockwise app in Dec 2021 and that page now 301s
// straight to a marketing page, dropping the ?code=/&state= query string in
// the process - and since the redirect_uri is baked into this client id's
// registration, we can't just point it somewhere we control.
//
// So doLogin() opens the auth page in an embedded, off-the-record
// QWebEngineView (Qt6 WebEngine, via the miqt bindings) with a URL request
// interceptor watching for redirectHost. When that request is about to go
// out, we block it before it ever hits the network, read the code/state off
// its URL, and quit the Qt event loop - no fake TLS cert, no browser
// warning, no external browser process at all.
const (
	clientID       = "e7ce535d93522896"
	scope          = "https://identity.mozilla.com/apps/oldsync"
	authURL        = "https://accounts.firefox.com/authorization"
	tokenURL       = "https://oauth.accounts.firefox.com/v1/token"
	tokenServerURL = "https://token.services.mozilla.com/1.0/sync/1.5"
	redirectHost   = "lockbox.firefox.com"
)

type credentials struct {
	RefreshToken string `json:"refresh_token"`
	KeyID        string `json:"key_id"`
	SyncKey      string `json:"sync_key_b64"` // base64url of the 64-byte oldsync scoped key
}

func credentialsPath() string {
	home, _ := os.UserHomeDir()
	return filepath.Join(home, ".config", "mozsync", "credentials.json")
}

func main() {
	usage := "usage: mozsync <login|dump|chrome-import <path>|falkon-import <path>>"
	if len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, usage)
		os.Exit(1)
	}
	var err error
	switch os.Args[1] {
	case "login":
		err = doLogin()
	case "dump":
		err = doDump()
	case "chrome-import":
		if len(os.Args) < 3 {
			fmt.Fprintln(os.Stderr, usage)
			os.Exit(1)
		}
		err = doChromeImport(os.Args[2])
	case "falkon-import":
		if len(os.Args) < 3 {
			fmt.Fprintln(os.Stderr, usage)
			os.Exit(1)
		}
		err = doFalkonImport(os.Args[2])
	default:
		fmt.Fprintln(os.Stderr, usage)
		os.Exit(1)
	}
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
}

// ---------- login (one-time browser-based OAuth setup) ----------

func doLogin() error {
	verifier := randB64URL(32)
	challenge := b64url(sha256sum([]byte(verifier)))
	state := randB64URL(16)

	priv, err := ecdh.P256().GenerateKey(rand.Reader)
	if err != nil {
		return err
	}
	pub := priv.PublicKey().Bytes() // uncompressed point: 0x04 || X(32) || Y(32)
	x, y := pub[1:33], pub[33:65]
	jwk := map[string]string{"crv": "P-256", "kty": "EC", "x": b64url(x), "y": b64url(y)}
	jwkJSON, err := json.Marshal(jwk)
	if err != nil {
		return err
	}

	q := url.Values{}
	q.Set("client_id", clientID)
	q.Set("scope", scope)
	q.Set("state", state)
	q.Set("access_type", "offline")
	q.Set("code_challenge_method", "S256")
	q.Set("code_challenge", challenge)
	q.Set("keys_jwk", b64url(jwkJSON))
	q.Set("response_type", "code")
	fullAuthURL := authURL + "?" + q.Encode()

	code, gotState, err := runLoginWindow(fullAuthURL)
	if err != nil {
		return err
	}
	if code == "" {
		return fmt.Errorf("login window closed before signing in")
	}
	if gotState != state {
		return fmt.Errorf("state mismatch (got %q, want %q) - aborting", gotState, state)
	}

	var tokenResp struct {
		AccessToken  string `json:"access_token"`
		RefreshToken string `json:"refresh_token"`
		KeysJWE      string `json:"keys_jwe"`
	}
	err = postJSON(tokenURL, map[string]string{
		"client_id":     clientID,
		"grant_type":    "authorization_code",
		"code":          code,
		"code_verifier": verifier,
	}, &tokenResp)
	if err != nil {
		return fmt.Errorf("token exchange failed: %w", err)
	}
	if tokenResp.KeysJWE == "" {
		return fmt.Errorf("no keys_jwe in token response")
	}

	kid, kBytes, err := decryptKeysJWE(tokenResp.KeysJWE, priv)
	if err != nil {
		return fmt.Errorf("decrypting keys_jwe: %w", err)
	}

	creds := credentials{RefreshToken: tokenResp.RefreshToken, KeyID: kid, SyncKey: b64url(kBytes)}
	if err := saveCredentials(creds); err != nil {
		return err
	}
	fmt.Println()
	fmt.Println("Saved credentials to", credentialsPath())
	fmt.Println("This file grants ongoing access to your Mozilla account's sync data - keep it private (mode 0600).")
	fmt.Println("Run `mozsync dump` to fetch your bookmarks.")
	return nil
}

// ---------- embedded login window ----------
//
// runLoginWindow opens fullAuthURL in an off-the-record QWebEngineView and
// watches every outgoing request with a QWebEngineUrlRequestInterceptor.
// When one targets redirectHost, we block it (so it never actually hits
// Mozilla's broken redirect endpoint) and read code/state straight off its
// URL. Interception runs on Chromium's IO thread, not the Qt GUI thread, so
// the interceptor callback only ever touches a mutex-guarded plain Go value
// - a QTimer polling on the GUI thread is what actually calls
// QCoreApplication_Quit() once a result shows up.
func runLoginWindow(fullAuthURL string) (code, state string, err error) {
	app := qt6.NewQApplication(os.Args)
	defer app.Delete()

	var (
		mu     sync.Mutex
		result *struct{ code, state string }
	)

	profile := webengine.NewQWebEngineProfile() // no name -> off-the-record, nothing persisted
	interceptor := webengine.NewQWebEngineUrlRequestInterceptor()
	interceptor.OnInterceptRequest(func(info *webengine.QWebEngineUrlRequestInfo) {
		u := info.RequestUrl()
		if u.Host() != redirectHost {
			return
		}
		info.Block(true)
		vals, _ := url.ParseQuery(u.Query())
		mu.Lock()
		if result == nil {
			result = &struct{ code, state string }{vals.Get("code"), vals.Get("state")}
		}
		mu.Unlock()
	})
	profile.SetUrlRequestInterceptor(interceptor)

	view := webengine.NewQWebEngineView3(profile)
	view.SetWindowTitle("Sign in to your Mozilla Account")
	view.Resize(900, 700)
	view.Load(qt6.NewQUrl3(fullAuthURL))
	view.Show()

	const timeout = 5 * time.Minute
	deadline := time.Now().Add(timeout)
	poll := qt6.NewQTimer()
	poll.OnTimeout(func() {
		mu.Lock()
		r := result
		mu.Unlock()
		if r != nil || time.Now().After(deadline) {
			qt6.QCoreApplication_Quit()
		}
	})
	poll.Start(150)

	qt6.QCoreApplication_Exec() // blocks until Quit(); also returns if the user closes the window

	mu.Lock()
	defer mu.Unlock()
	if result == nil {
		return "", "", nil // window closed / timed out without a captured redirect
	}
	return result.code, result.state, nil
}

func saveCredentials(c credentials) error {
	path := credentialsPath()
	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
		return err
	}
	data, err := json.MarshalIndent(c, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(path, data, 0o600)
}

func loadCredentials() (credentials, error) {
	var c credentials
	data, err := os.ReadFile(credentialsPath())
	if err != nil {
		return c, err
	}
	err = json.Unmarshal(data, &c)
	return c, err
}

// ---------- JWE (ECDH-ES + A256GCM), per RFC 7518 4.6 ----------

func decryptKeysJWE(jwe string, priv *ecdh.PrivateKey) (kid string, key []byte, err error) {
	parts := strings.Split(jwe, ".")
	if len(parts) != 5 {
		return "", nil, fmt.Errorf("malformed JWE: expected 5 segments, got %d", len(parts))
	}
	headerB64, ivB64, ctB64, tagB64 := parts[0], parts[2], parts[3], parts[4]

	headerRaw, err := base64.RawURLEncoding.DecodeString(headerB64)
	if err != nil {
		return "", nil, err
	}
	var header struct {
		Enc string `json:"enc"`
		Alg string `json:"alg"`
		EPK struct {
			X string `json:"x"`
			Y string `json:"y"`
		} `json:"epk"`
	}
	if err := json.Unmarshal(headerRaw, &header); err != nil {
		return "", nil, err
	}
	if header.Alg != "ECDH-ES" || header.Enc != "A256GCM" {
		return "", nil, fmt.Errorf("unexpected JWE alg/enc: %s/%s", header.Alg, header.Enc)
	}

	epkX, err := base64.RawURLEncoding.DecodeString(header.EPK.X)
	if err != nil {
		return "", nil, err
	}
	epkY, err := base64.RawURLEncoding.DecodeString(header.EPK.Y)
	if err != nil {
		return "", nil, err
	}
	epkBytes := append([]byte{0x04}, append(epkX, epkY...)...)
	serverPub, err := ecdh.P256().NewPublicKey(epkBytes)
	if err != nil {
		return "", nil, err
	}
	shared, err := priv.ECDH(serverPub)
	if err != nil {
		return "", nil, err
	}

	cek := concatKDF(shared, header.Enc, 32) // A256GCM -> 256-bit CEK

	iv, err := base64.RawURLEncoding.DecodeString(ivB64)
	if err != nil {
		return "", nil, err
	}
	ciphertext, err := base64.RawURLEncoding.DecodeString(ctB64)
	if err != nil {
		return "", nil, err
	}
	tag, err := base64.RawURLEncoding.DecodeString(tagB64)
	if err != nil {
		return "", nil, err
	}

	block, err := aes.NewCipher(cek)
	if err != nil {
		return "", nil, err
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return "", nil, err
	}
	aad := []byte(headerB64) // JWE AAD is the raw base64url header segment itself
	plaintext, err := gcm.Open(nil, iv, append(ciphertext, tag...), aad)
	if err != nil {
		return "", nil, fmt.Errorf("GCM open failed: %w", err)
	}

	var scoped map[string]struct {
		K   string `json:"k"`
		Kid string `json:"kid"`
	}
	if err := json.Unmarshal(plaintext, &scoped); err != nil {
		return "", nil, err
	}
	sk, ok := scoped[scope]
	if !ok {
		return "", nil, fmt.Errorf("no scoped key for %s in keys_jwe payload", scope)
	}
	kBytes, err := base64.RawURLEncoding.DecodeString(sk.K)
	if err != nil {
		return "", nil, err
	}
	return sk.Kid, kBytes, nil
}

// concatKDF is the single-round NIST SP 800-56A Concat KDF used by JWE ECDH-ES
// direct agreement, with empty PartyUInfo/PartyVInfo (no apu/apv exchanged here).
func concatKDF(z []byte, algID string, keyLenBytes int) []byte {
	algIDBytes := []byte(algID)
	otherInfo := appendUint32(nil, uint32(len(algIDBytes)))
	otherInfo = append(otherInfo, algIDBytes...)
	otherInfo = appendUint32(otherInfo, 0) // PartyUInfo length (empty)
	otherInfo = appendUint32(otherInfo, 0) // PartyVInfo length (empty)
	otherInfo = appendUint32(otherInfo, uint32(keyLenBytes*8))

	h := sha256.New()
	h.Write(appendUint32(nil, 1)) // round counter; one round covers 256 bits
	h.Write(z)
	h.Write(otherInfo)
	sum := h.Sum(nil)
	return sum[:keyLenBytes]
}

func appendUint32(b []byte, v uint32) []byte {
	return append(b, byte(v>>24), byte(v>>16), byte(v>>8), byte(v))
}

// ---------- Sync 1.5 storage records ----------

type syncKeyBundle struct {
	EncKey  []byte
	HMACKey []byte
}

func decryptBSOPayload(payloadJSON string, bundle syncKeyBundle) ([]byte, error) {
	var payload struct {
		Ciphertext string `json:"ciphertext"`
		IV         string `json:"IV"`
		HMAC       string `json:"hmac"`
	}
	if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
		return nil, err
	}
	mac := hmac.New(sha256.New, bundle.HMACKey)
	mac.Write([]byte(payload.Ciphertext)) // HMAC covers the base64 ciphertext string itself
	if hex.EncodeToString(mac.Sum(nil)) != payload.HMAC {
		return nil, fmt.Errorf("HMAC verification failed")
	}
	iv, err := base64.StdEncoding.DecodeString(payload.IV)
	if err != nil {
		return nil, err
	}
	ct, err := base64.StdEncoding.DecodeString(payload.Ciphertext)
	if err != nil {
		return nil, err
	}
	if len(ct)%aes.BlockSize != 0 {
		return nil, fmt.Errorf("ciphertext not a multiple of block size")
	}
	block, err := aes.NewCipher(bundle.EncKey)
	if err != nil {
		return nil, err
	}
	out := make([]byte, len(ct))
	cipher.NewCBCDecrypter(block, iv).CryptBlocks(out, ct)
	return pkcs7Unpad(out)
}

func pkcs7Unpad(b []byte) ([]byte, error) {
	if len(b) == 0 {
		return nil, fmt.Errorf("empty plaintext")
	}
	pad := int(b[len(b)-1])
	if pad == 0 || pad > len(b) {
		return nil, fmt.Errorf("invalid padding")
	}
	return b[:len(b)-pad], nil
}

func doDump() error {
	creds, err := loadCredentials()
	if err != nil {
		return fmt.Errorf("no credentials found, run `mozsync login` first: %w", err)
	}
	masterKey, err := base64.RawURLEncoding.DecodeString(creds.SyncKey)
	if err != nil || len(masterKey) != 64 {
		return fmt.Errorf("stored sync key is invalid, re-run `mozsync login`")
	}
	masterBundle := syncKeyBundle{EncKey: masterKey[:32], HMACKey: masterKey[32:64]}

	accessToken, err := refreshAccessToken(creds.RefreshToken)
	if err != nil {
		return fmt.Errorf("refreshing access token: %w", err)
	}
	hawkID, hawkKey, apiEndpoint, err := getStorageCredentials(accessToken, creds.KeyID)
	if err != nil {
		return fmt.Errorf("fetching storage credentials: %w", err)
	}

	keysPayload, err := storageGET(apiEndpoint, "/storage/crypto/keys", hawkID, hawkKey)
	if err != nil {
		return fmt.Errorf("fetching crypto/keys: %w", err)
	}
	var keysBSO struct {
		Payload string `json:"payload"`
	}
	if err := json.Unmarshal(keysPayload, &keysBSO); err != nil {
		return err
	}
	plain, err := decryptBSOPayload(keysBSO.Payload, masterBundle)
	if err != nil {
		return fmt.Errorf("decrypting crypto/keys: %w", err)
	}
	var keysRecord struct {
		Default     []string            `json:"default"`
		Collections map[string][]string `json:"collections"`
	}
	if err := json.Unmarshal(plain, &keysRecord); err != nil {
		return err
	}
	bundleFor := func(collection string) (syncKeyBundle, error) {
		pair := keysRecord.Default
		if override, ok := keysRecord.Collections[collection]; ok {
			pair = override
		}
		if len(pair) != 2 {
			return syncKeyBundle{}, fmt.Errorf("no usable key bundle for collection %s", collection)
		}
		enc, err := base64.StdEncoding.DecodeString(pair[0])
		if err != nil {
			return syncKeyBundle{}, err
		}
		h, err := base64.StdEncoding.DecodeString(pair[1])
		if err != nil {
			return syncKeyBundle{}, err
		}
		return syncKeyBundle{EncKey: enc, HMACKey: h}, nil
	}
	bmBundle, err := bundleFor("bookmarks")
	if err != nil {
		return err
	}

	bmRaw, err := storageGET(apiEndpoint, "/storage/bookmarks?full=true", hawkID, hawkKey)
	if err != nil {
		return fmt.Errorf("fetching bookmarks: %w", err)
	}
	var bsos []struct {
		ID      string `json:"id"`
		Payload string `json:"payload"`
	}
	if err := json.Unmarshal(bmRaw, &bsos); err != nil {
		return err
	}

	var records []json.RawMessage
	for _, bso := range bsos {
		plain, err := decryptBSOPayload(bso.Payload, bmBundle)
		if err != nil {
			fmt.Fprintf(os.Stderr, "warning: skipping record %s: %v\n", bso.ID, err)
			continue
		}
		records = append(records, json.RawMessage(plain))
	}

	out, err := json.MarshalIndent(records, "", "  ")
	if err != nil {
		return err
	}
	fmt.Println(string(out))
	return nil
}

// ---------- chrome-import: merge a `mozsync dump` into a Chromium profile's Bookmarks file ----------
//
// Reads the flat dump array (Firefox Places Sync records) from stdin, and for
// each of Firefox's special roots that has children (toolbar, menu, unfiled,
// mobile), builds one labeled wrapper folder holding the full imported
// subtree and appends it into the corresponding Chromium root - bookmark_bar
// for toolbar, other for menu/unfiled, synced for mobile. Existing bookmarks
// are never touched or removed, only appended to, and the previous file is
// always backed up first. The Chromium file is read/written as a generic
// map[string]interface{} rather than a fixed struct so fields Chromium itself
// relies on (like a bookmark's meta_info) survive the round-trip untouched.

type ffRecord struct {
	ID        string   `json:"id"`
	Type      string   `json:"type"`
	Title     string   `json:"title"`
	BmkURI    string   `json:"bmkUri"`
	DateAdded int64    `json:"dateAdded"`
	Children  []string `json:"children"`
	Deleted   bool     `json:"deleted"`
}

func doChromeImport(path string) error {
	dumpBytes, err := io.ReadAll(os.Stdin)
	if err != nil {
		return fmt.Errorf("reading dump from stdin: %w", err)
	}
	var records []ffRecord
	if err := json.Unmarshal(dumpBytes, &records); err != nil {
		return fmt.Errorf("parsing dump JSON: %w", err)
	}
	byID := make(map[string]ffRecord, len(records))
	for _, r := range records {
		if !r.Deleted {
			byID[r.ID] = r
		}
	}

	if chromiumRunning() {
		fmt.Fprintln(os.Stderr, "warning: Chromium appears to be running - it may overwrite this import on its")
		fmt.Fprintln(os.Stderr, "         next save. Fully quit Chromium and re-run this if the import doesn't stick.")
	}

	existing, err := os.ReadFile(path)
	if err != nil {
		return fmt.Errorf("reading %s: %w", path, err)
	}
	var cf map[string]interface{}
	if err := json.Unmarshal(existing, &cf); err != nil {
		return fmt.Errorf("parsing existing Chromium bookmarks file: %w", err)
	}
	roots, ok := cf["roots"].(map[string]interface{})
	if !ok {
		return fmt.Errorf("existing Chromium bookmarks file has no \"roots\" object")
	}
	bar, ok1 := roots["bookmark_bar"].(map[string]interface{})
	other, ok2 := roots["other"].(map[string]interface{})
	synced, ok3 := roots["synced"].(map[string]interface{})
	if !ok1 || !ok2 || !ok3 {
		return fmt.Errorf("existing Chromium bookmarks file is missing an expected root")
	}

	backupPath := path + ".mozsync-bak"
	if err := os.WriteFile(backupPath, existing, 0o600); err != nil {
		return fmt.Errorf("backing up existing bookmarks file: %w", err)
	}

	nextID := maxChromeID(roots) + 1

	// Merge directly into each root's top level, matching existing items by
	// (name, type): an existing bookmark's url is overwritten, an existing
	// folder is recursed into and merged, and only genuinely new items are
	// appended. No wrapper folder, and re-running this is idempotent instead
	// of piling up a fresh dated folder each time.
	mergeRoot := func(target map[string]interface{}, ffRootID string) {
		root, ok := byID[ffRootID]
		if !ok {
			return
		}
		existingKids, _ := target["children"].([]interface{})
		target["children"] = mergeChromeChildren(existingKids, byID, root.Children, &nextID)
	}
	mergeRoot(bar, "toolbar")
	mergeRoot(other, "menu")
	mergeRoot(other, "unfiled")
	mergeRoot(synced, "mobile")

	cf["checksum"] = computeChromeChecksum(bar, other, synced)

	out, err := json.MarshalIndent(cf, "", "   ")
	if err != nil {
		return err
	}
	tmpPath := path + ".mozsync-tmp"
	if err := os.WriteFile(tmpPath, out, 0o600); err != nil {
		return err
	}
	if err := os.Rename(tmpPath, path); err != nil {
		return err
	}

	fmt.Println("Imported into", path)
	fmt.Println("Backup of the previous file saved to", backupPath)
	fmt.Println("Fully quit and relaunch Chromium to see the merged bookmarks.")
	return nil
}

// mergeChromeChildren upserts each Firefox child (by title+type match) into
// an existing Chromium children array: url nodes get their url overwritten,
// folder nodes are recursed into, and anything with no match is appended as
// a freshly built subtree.
func mergeChromeChildren(existing []interface{}, byID map[string]ffRecord, ffChildIDs []string, nextID *int) []interface{} {
	for _, childID := range ffChildIDs {
		r, ok := byID[childID]
		if !ok {
			continue
		}
		switch r.Type {
		case "bookmark":
			if r.BmkURI == "" {
				continue
			}
			existing = upsertChromeURL(existing, r, nextID)
		case "folder":
			existing = upsertChromeFolder(existing, r, byID, nextID)
		}
	}
	return existing
}

func upsertChromeURL(existing []interface{}, r ffRecord, nextID *int) []interface{} {
	for _, e := range existing {
		m, ok := e.(map[string]interface{})
		if !ok {
			continue
		}
		name, _ := m["name"].(string)
		t, _ := m["type"].(string)
		if name == r.Title && t == "url" {
			m["url"] = r.BmkURI
			return existing
		}
	}
	n := map[string]interface{}{
		"id": strconv.Itoa(*nextID), "guid": newGUID(), "name": r.Title, "type": "url",
		"url": r.BmkURI, "date_added": chromeTimestamp(r.DateAdded), "date_last_used": "0", "date_modified": "0",
	}
	(*nextID)++
	return append(existing, n)
}

func upsertChromeFolder(existing []interface{}, r ffRecord, byID map[string]ffRecord, nextID *int) []interface{} {
	for _, e := range existing {
		m, ok := e.(map[string]interface{})
		if !ok {
			continue
		}
		name, _ := m["name"].(string)
		t, _ := m["type"].(string)
		if name == r.Title && t == "folder" {
			kids, _ := m["children"].([]interface{})
			m["children"] = mergeChromeChildren(kids, byID, r.Children, nextID)
			return existing
		}
	}
	if n := buildChromeNode(byID, r.ID, nextID); n != nil {
		existing = append(existing, n)
	}
	return existing
}

func buildChromeNode(byID map[string]ffRecord, id string, nextID *int) map[string]interface{} {
	r, ok := byID[id]
	if !ok {
		return nil
	}
	switch r.Type {
	case "bookmark":
		if r.BmkURI == "" {
			return nil
		}
		n := map[string]interface{}{
			"id": strconv.Itoa(*nextID), "guid": newGUID(), "name": r.Title, "type": "url",
			"url": r.BmkURI, "date_added": chromeTimestamp(r.DateAdded), "date_last_used": "0", "date_modified": "0",
		}
		(*nextID)++
		return n
	case "folder":
		myID := strconv.Itoa(*nextID)
		(*nextID)++
		var kids []interface{}
		for _, childID := range r.Children {
			if child := buildChromeNode(byID, childID, nextID); child != nil {
				kids = append(kids, child)
			}
		}
		n := map[string]interface{}{
			"id": myID, "guid": newGUID(), "name": r.Title, "type": "folder",
			"date_added": chromeTimestamp(r.DateAdded), "date_last_used": "0", "date_modified": "0",
		}
		if kids != nil {
			n["children"] = kids
		}
		return n
	default:
		return nil // skip queries, separators, livemarks, etc.
	}
}

func maxChromeID(roots map[string]interface{}) int {
	max := 0
	var walk func(n interface{})
	walk = func(n interface{}) {
		m, ok := n.(map[string]interface{})
		if !ok {
			return
		}
		if idStr, ok := m["id"].(string); ok {
			if v, err := strconv.Atoi(idStr); err == nil && v > max {
				max = v
			}
		}
		if kids, ok := m["children"].([]interface{}); ok {
			for _, k := range kids {
				walk(k)
			}
		}
	}
	for _, v := range roots {
		walk(v)
	}
	return max
}

// computeChromeChecksum reproduces Chromium's BookmarkCodec checksum: an MD5
// over (id, name[, url if a url node]) for every node, visited pre-order,
// across bookmark_bar/other/synced in that order. Best-effort - if a given
// Chromium version's algorithm has drifted, worst case is a one-time "your
// bookmarks were changed outside Chrome" notice on next launch; the data
// itself is unaffected either way.
func computeChromeChecksum(roots ...map[string]interface{}) string {
	h := md5.New()
	var walk func(n map[string]interface{})
	walk = func(n map[string]interface{}) {
		if n == nil {
			return
		}
		if idStr, ok := n["id"].(string); ok {
			io.WriteString(h, idStr)
		}
		if name, ok := n["name"].(string); ok {
			io.WriteString(h, name)
		}
		if t, _ := n["type"].(string); t == "url" {
			if u, ok := n["url"].(string); ok {
				io.WriteString(h, u)
			}
		}
		if kids, ok := n["children"].([]interface{}); ok {
			for _, k := range kids {
				if km, ok := k.(map[string]interface{}); ok {
					walk(km)
				}
			}
		}
	}
	for _, r := range roots {
		walk(r)
	}
	return hex.EncodeToString(h.Sum(nil))
}

// chromeEpochDeltaMicros is the offset between the Unix epoch (1970-01-01)
// and the Windows/WebKit epoch (1601-01-01) that Chromium's bookmark
// timestamps are measured from, in microseconds.
const chromeEpochDeltaMicros = 11644473600000000

func chromeTimestamp(unixMs int64) string {
	return strconv.FormatInt(unixMs*1000+chromeEpochDeltaMicros, 10)
}

func newGUID() string {
	b := make([]byte, 16)
	_, _ = rand.Read(b)
	b[6] = (b[6] & 0x0f) | 0x40
	b[8] = (b[8] & 0x3f) | 0x80
	return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}

func chromiumRunning() bool {
	out, err := exec.Command("pgrep", "-f", "org.chromium.Chromium").Output()
	return err == nil && len(strings.TrimSpace(string(out))) > 0
}

// ---------- falkon-import: merge a `mozsync dump` into Falkon's bookmarks.json ----------
//
// Falkon (unlike Chromium) stores bookmarks as plain JSON with no ids,
// checksum, or timestamp bookkeeping - just nested folders under three roots:
// bookmark_bar, bookmark_menu, other. Same safe-append/backup approach as
// chrome-import: existing bookmarks are never touched, only appended to.

func doFalkonImport(path string) error {
	dumpBytes, err := io.ReadAll(os.Stdin)
	if err != nil {
		return fmt.Errorf("reading dump from stdin: %w", err)
	}
	var records []ffRecord
	if err := json.Unmarshal(dumpBytes, &records); err != nil {
		return fmt.Errorf("parsing dump JSON: %w", err)
	}
	byID := make(map[string]ffRecord, len(records))
	for _, r := range records {
		if !r.Deleted {
			byID[r.ID] = r
		}
	}

	if falkonRunning() {
		fmt.Fprintln(os.Stderr, "warning: Falkon appears to be running - it may overwrite this import on its")
		fmt.Fprintln(os.Stderr, "         next save. Fully quit Falkon and re-run this if the import doesn't stick.")
	}

	existing, err := os.ReadFile(path)
	if err != nil {
		return fmt.Errorf("reading %s: %w", path, err)
	}
	var bf map[string]interface{}
	if err := json.Unmarshal(existing, &bf); err != nil {
		return fmt.Errorf("parsing existing Falkon bookmarks file: %w", err)
	}
	roots, ok := bf["roots"].(map[string]interface{})
	if !ok {
		return fmt.Errorf("existing Falkon bookmarks file has no \"roots\" object")
	}
	bar, ok1 := roots["bookmark_bar"].(map[string]interface{})
	menu, ok2 := roots["bookmark_menu"].(map[string]interface{})
	other, ok3 := roots["other"].(map[string]interface{})
	if !ok1 || !ok2 || !ok3 {
		return fmt.Errorf("existing Falkon bookmarks file is missing an expected root")
	}

	backupPath := path + ".mozsync-bak"
	if err := os.WriteFile(backupPath, existing, 0o600); err != nil {
		return fmt.Errorf("backing up existing bookmarks file: %w", err)
	}

	// Merge directly into each root's top level, matching existing items by
	// (name, type): an existing bookmark's url is overwritten, an existing
	// folder is recursed into and merged, and only genuinely new items are
	// appended. No wrapper folder, and re-running this is idempotent instead
	// of piling up a fresh dated folder each time.
	mergeRoot := func(target map[string]interface{}, ffRootID string) {
		root, ok := byID[ffRootID]
		if !ok {
			return
		}
		existingKids, _ := target["children"].([]interface{})
		target["children"] = mergeFalkonChildren(existingKids, byID, root.Children)
	}
	mergeRoot(bar, "toolbar")
	mergeRoot(menu, "menu")
	mergeRoot(other, "unfiled")
	mergeRoot(other, "mobile")

	out, err := json.MarshalIndent(bf, "", " ")
	if err != nil {
		return err
	}
	tmpPath := path + ".mozsync-tmp"
	if err := os.WriteFile(tmpPath, out, 0o600); err != nil {
		return err
	}
	if err := os.Rename(tmpPath, path); err != nil {
		return err
	}

	fmt.Println("Imported into", path)
	fmt.Println("Backup of the previous file saved to", backupPath)
	fmt.Println("Fully quit and relaunch Falkon to see the merged bookmarks.")
	return nil
}

// mergeFalkonChildren upserts each Firefox child (by title+type match) into
// an existing Falkon children array: url nodes get their url overwritten,
// folder nodes are recursed into, and anything with no match is appended as
// a freshly built subtree.
func mergeFalkonChildren(existing []interface{}, byID map[string]ffRecord, ffChildIDs []string) []interface{} {
	for _, childID := range ffChildIDs {
		r, ok := byID[childID]
		if !ok {
			continue
		}
		switch r.Type {
		case "bookmark":
			if r.BmkURI == "" {
				continue
			}
			existing = upsertFalkonURL(existing, r)
		case "folder":
			existing = upsertFalkonFolder(existing, r, byID)
		}
	}
	return existing
}

func upsertFalkonURL(existing []interface{}, r ffRecord) []interface{} {
	for _, e := range existing {
		m, ok := e.(map[string]interface{})
		if !ok {
			continue
		}
		name, _ := m["name"].(string)
		t, _ := m["type"].(string)
		if name == r.Title && t == "url" {
			m["url"] = r.BmkURI
			return existing
		}
	}
	n := map[string]interface{}{
		"description": "", "keyword": "", "name": r.Title, "type": "url", "url": r.BmkURI, "visit_count": 0,
	}
	return append(existing, n)
}

func upsertFalkonFolder(existing []interface{}, r ffRecord, byID map[string]ffRecord) []interface{} {
	for _, e := range existing {
		m, ok := e.(map[string]interface{})
		if !ok {
			continue
		}
		name, _ := m["name"].(string)
		t, _ := m["type"].(string)
		if name == r.Title && t == "folder" {
			kids, _ := m["children"].([]interface{})
			m["children"] = mergeFalkonChildren(kids, byID, r.Children)
			return existing
		}
	}
	if n := buildFalkonNode(byID, r.ID); n != nil {
		existing = append(existing, n)
	}
	return existing
}

func buildFalkonNode(byID map[string]ffRecord, id string) map[string]interface{} {
	r, ok := byID[id]
	if !ok {
		return nil
	}
	switch r.Type {
	case "bookmark":
		if r.BmkURI == "" {
			return nil
		}
		return map[string]interface{}{
			"description": "", "keyword": "", "name": r.Title, "type": "url",
			"url": r.BmkURI, "visit_count": 0,
		}
	case "folder":
		var kids []interface{}
		for _, childID := range r.Children {
			if child := buildFalkonNode(byID, childID); child != nil {
				kids = append(kids, child)
			}
		}
		if kids == nil {
			kids = []interface{}{}
		}
		return map[string]interface{}{
			"children": kids, "description": "", "expanded": true, "expanded_sidebar": true,
			"name": r.Title, "type": "folder",
		}
	default:
		return nil // skip queries, separators, livemarks, etc.
	}
}

func falkonRunning() bool {
	out, err := exec.Command("pgrep", "-f", "org.kde.falkon").Output()
	return err == nil && len(strings.TrimSpace(string(out))) > 0
}

func refreshAccessToken(refreshToken string) (string, error) {
	var resp struct {
		AccessToken string `json:"access_token"`
	}
	err := postJSON(tokenURL, map[string]string{
		"client_id":     clientID,
		"grant_type":    "refresh_token",
		"refresh_token": refreshToken,
		"scope":         scope,
	}, &resp)
	if err != nil {
		return "", err
	}
	if resp.AccessToken == "" {
		return "", fmt.Errorf("empty access token in refresh response")
	}
	return resp.AccessToken, nil
}

func getStorageCredentials(accessToken, keyID string) (id, key, apiEndpoint string, err error) {
	req, err := http.NewRequest("GET", tokenServerURL, nil)
	if err != nil {
		return "", "", "", err
	}
	req.Header.Set("Authorization", "Bearer "+accessToken)
	req.Header.Set("X-KeyID", keyID)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", "", "", err
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	if resp.StatusCode != 200 {
		return "", "", "", fmt.Errorf("tokenserver returned %d: %s", resp.StatusCode, body)
	}
	var ts struct {
		ID          string `json:"id"`
		Key         string `json:"key"`
		APIEndpoint string `json:"api_endpoint"`
	}
	if err := json.Unmarshal(body, &ts); err != nil {
		return "", "", "", err
	}
	return ts.ID, ts.Key, ts.APIEndpoint, nil
}

func storageGET(apiEndpoint, path, hawkID, hawkKey string) ([]byte, error) {
	fullURL := apiEndpoint + path
	req, err := http.NewRequest("GET", fullURL, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", hawkHeader("GET", fullURL, hawkID, hawkKey))
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	if resp.StatusCode != 200 {
		return nil, fmt.Errorf("storage server returned %d: %s", resp.StatusCode, body)
	}
	return body, nil
}

// ---------- Hawk request signing ----------

func hawkHeader(method, rawURL, id, key string) string {
	u, _ := url.Parse(rawURL)
	ts := time.Now().Unix()
	nonce := randB64URL(6)
	resource := u.Path
	if u.RawQuery != "" {
		resource += "?" + u.RawQuery
	}
	port := u.Port()
	if port == "" {
		port = "443"
	}
	normalized := fmt.Sprintf("hawk.1.header\n%d\n%s\n%s\n%s\n%s\n%s\n\n\n",
		ts, nonce, method, resource, u.Hostname(), port)
	mac := hmac.New(sha256.New, []byte(key))
	mac.Write([]byte(normalized))
	macB64 := base64.StdEncoding.EncodeToString(mac.Sum(nil))
	return fmt.Sprintf(`Hawk id="%s", ts="%d", nonce="%s", mac="%s"`, id, ts, nonce, macB64)
}

// ---------- small helpers ----------

func postJSON(u string, body interface{}, out interface{}) error {
	data, err := json.Marshal(body)
	if err != nil {
		return err
	}
	resp, err := http.Post(u, "application/json", strings.NewReader(string(data)))
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	respBody, _ := io.ReadAll(resp.Body)
	if resp.StatusCode != 200 {
		return fmt.Errorf("HTTP %d: %s", resp.StatusCode, respBody)
	}
	return json.Unmarshal(respBody, out)
}

func randB64URL(n int) string {
	b := make([]byte, n)
	_, _ = rand.Read(b)
	return b64url(b)
}

func b64url(b []byte) string {
	return base64.RawURLEncoding.EncodeToString(b)
}

func sha256sum(b []byte) []byte {
	s := sha256.Sum256(b)
	return s[:]
}