Skip to main content

max / balanced_breakfast

Embed SyncKit API key, remove key entry UI, add dynamic tiers - Add EMBEDDED_API_KEY with option_env fallback in state.rs - Remove sync_test_api_key/sync_save_api_key commands - Replace API key form with single "Connect to Makenot.work" button - Add sync_get_tiers command for server-driven pricing - Subscription UI fetches pricing dynamically from server Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-11 18:38 UTC
Commit: 335155c7e237e7fadc557ef974f336f901155e2b
Parent: fad957c
5 files changed, +80 insertions, -121 deletions
@@ -236,17 +236,11 @@
236 236 * @param {string} apiKey - API key to validate.
237 237 * @returns {Promise<string>} App name on success.
238 238 */
239 - testApiKey: (apiKey) => invoke('sync_test_api_key', { apiKey }),
240 - /**
241 - * Save an API key and create the sync client.
242 - * @param {string} apiKey - API key to persist.
243 - * @returns {Promise<void>}
244 - */
245 - saveApiKey: (apiKey) => invoke('sync_save_api_key', { apiKey }),
246 239 /**
247 240 * Get current sync status.
248 241 * @returns {Promise<Object>} Status with configured, authenticated, encryptionReady, etc.
249 242 */
243 + getTiers: () => invoke('sync_get_tiers'),
250 244 status: () => invoke('sync_status'),
251 245 /**
252 246 * Start OAuth2 PKCE auth flow.
@@ -48,90 +48,25 @@
48 48 }
49 49 }
50 50
51 - // ── State 1: API Key Setup ──
51 + // ── State 1: Connect ──
52 52
53 53 /**
54 - * Render API key input form for initial connection.
54 + * Render the connect button for OAuth authentication.
55 55 * @param {HTMLElement} container - Modal body element.
56 56 */
57 57 function renderConnect(container) {
58 58 const div = document.createElement('div');
59 59 div.className = 'sync-connect';
60 + div.style.textAlign = 'center';
61 + div.style.padding = '2rem 0';
60 62 div.innerHTML =
61 - '<p>Sync your feeds and preferences across devices via Makenot.work.</p>' +
62 - '<p style="margin-bottom: 0.5rem;"><a href="https://makenot.work/docs/synckit-api" target="_blank">Get an API key</a></p>';
63 -
64 - const group = document.createElement('div');
65 - group.className = 'form-group';
66 - const label = document.createElement('label');
67 - label.textContent = 'API Key';
68 - const row = document.createElement('div');
69 - row.style.display = 'flex';
70 - row.style.gap = '0.5rem';
71 - const input = document.createElement('input');
72 - input.className = 'form-input';
73 - input.type = 'password';
74 - input.placeholder = 'sk_...';
75 - input.style.flex = '1';
76 - const testBtn = document.createElement('button');
77 - testBtn.className = 'btn';
78 - testBtn.textContent = 'Test';
79 -
80 - const statusDiv = document.createElement('div');
81 - statusDiv.style.fontSize = '0.875rem';
82 - statusDiv.style.marginTop = '0.5rem';
83 - statusDiv.style.display = 'none';
84 -
85 - const saveBtn = document.createElement('button');
86 - saveBtn.className = 'btn btn-primary';
87 - saveBtn.textContent = 'Save & Connect';
88 - saveBtn.style.marginTop = '0.75rem';
89 - saveBtn.style.display = 'none';
90 -
91 - testBtn.onclick = async () => {
92 - const key = input.value.trim();
93 - if (!key) return;
94 - testBtn.disabled = true;
95 - testBtn.textContent = 'Testing...';
96 - statusDiv.style.display = 'block';
97 - statusDiv.textContent = 'Validating...';
98 - statusDiv.style.color = '';
99 - saveBtn.style.display = 'none';
100 - try {
101 - const appName = await BB.api.sync.testApiKey(key);
102 - statusDiv.style.color = 'var(--accent-green, green)';
103 - statusDiv.textContent = 'Valid \u2014 ' + appName;
104 - saveBtn.style.display = '';
105 - } catch (err) {
106 - statusDiv.style.color = 'var(--accent-red, red)';
107 - statusDiv.textContent = err.message || String(err);
108 - saveBtn.style.display = 'none';
109 - } finally {
110 - testBtn.disabled = false;
111 - testBtn.textContent = 'Test';
112 - }
113 - };
114 -
115 - saveBtn.onclick = async () => {
116 - const key = input.value.trim();
117 - if (!key) return;
118 - try {
119 - await BB.api.sync.saveApiKey(key);
120 - BB.ui.showToast('API key saved!', 'success');
121 - const status = await BB.api.sync.status();
122 - renderState(container, status);
123 - } catch (err) {
124 - BB.ui.showToast('Failed to save: ' + (err.message || err), 'error');
125 - }
126 - };
127 -
128 - row.appendChild(input);
129 - row.appendChild(testBtn);
130 - group.appendChild(label);
131 - group.appendChild(row);
132 - div.appendChild(group);
133 - div.appendChild(statusDiv);
134 - div.appendChild(saveBtn);
63 + '<p style="margin-bottom: 1rem;">Sync your feeds and preferences across devices via Makenot.work.</p>' +
64 + '<p style="margin-bottom: 1.5rem; color: var(--text-secondary);">All data is encrypted on your device before it leaves.</p>';
65 + const btn = document.createElement('button');
66 + btn.className = 'btn btn-primary';
67 + btn.textContent = 'Connect to Makenot.work';
68 + btn.onclick = () => startAuth();
69 + div.appendChild(btn);
135 70 container.appendChild(div);
136 71 }
137 72
@@ -471,17 +406,34 @@
471 406 '<p class="sync-sub-active">Subscription active' +
472 407 (periodEnd ? ' (renews ' + periodEnd + ')' : '') + '</p>';
473 408 } else {
409 + // Fetch dynamic pricing
410 + let pricingHtml = '<p>Loading pricing...</p>';
411 + try {
412 + const tiers = await BB.api.sync.getTiers();
413 + if (tiers.length > 0) {
414 + const t = tiers[0];
415 + const monthly = '$' + (t.monthly_price_cents / 100);
416 + const annual = '$' + (t.annual_price_cents / 100);
417 + const monthlyCost = (t.monthly_price_cents / 100) * 12;
418 + const savings = monthlyCost - (t.annual_price_cents / 100);
419 + pricingHtml =
420 + '<p><strong>' + annual + '/year</strong>' + (savings > 0 ? ' (saves $' + savings + ' vs monthly)' : '') + ' or ' + monthly + '/month. ' +
421 + 'Annual saves you money because Stripe charges a fixed fee per transaction.</p>' +
422 + '<div style="display:flex;gap:0.5rem;margin-top:0.5rem;">' +
423 + '<button class="btn btn-primary" id="sync-sub-annual">Subscribe (' + annual + '/year)</button>' +
424 + '<button class="btn" id="sync-sub-monthly">' + monthly + '/month</button>' +
425 + '</div>';
426 + }
427 + } catch (_) { /* fall through */ }
474 428 banner.innerHTML =
475 429 '<div class="sync-sub-required">' +
476 430 '<p><strong>Subscription required</strong></p>' +
477 - '<p>Cloud sync requires a subscription ($1/month or $8/year). ' +
478 - 'Annual billing saves on payment processing fees.</p>' +
479 - '<div style="display:flex;gap:0.5rem;margin-top:0.5rem;">' +
480 - '<button class="btn btn-primary" id="sync-sub-annual">Subscribe ($8/year)</button>' +
481 - '<button class="btn" id="sync-sub-monthly">$1/month</button>' +
482 - '</div></div>';
483 - banner.querySelector('#sync-sub-annual').onclick = () => doSubscribe('annual');
484 - banner.querySelector('#sync-sub-monthly').onclick = () => doSubscribe('monthly');
431 + '<p>Cloud sync keeps your feeds, bookmarks, and settings in sync across devices with end-to-end encryption.</p>' +
432 + pricingHtml + '</div>';
433 + const annualBtn = banner.querySelector('#sync-sub-annual');
434 + const monthlyBtn = banner.querySelector('#sync-sub-monthly');
435 + if (annualBtn) annualBtn.onclick = () => doSubscribe('annual');
436 + if (monthlyBtn) monthlyBtn.onclick = () => doSubscribe('monthly');
485 437 }
486 438 } catch (_) {
487 439 // Non-fatal
@@ -491,12 +443,36 @@
491 443 async function doSubscribe(interval) {
492 444 try {
493 445 await BB.api.sync.subscribe(interval);
494 - BB.ui.showToast('Opening checkout in your browser...', 'success');
446 + BB.ui.showToast('Opening checkout in your browser. Complete payment, then return here.', 'success');
447 + pollForSubscription();
495 448 } catch (err) {
496 449 BB.ui.showToast('Subscribe failed: ' + (err.message || err), 'error');
497 450 }
498 451 }
499 452
453 + function pollForSubscription() {
454 + let attempts = 0;
455 + const maxAttempts = 120; // 10 minutes at 5s intervals
456 + const timer = setInterval(async () => {
457 + attempts++;
458 + if (attempts >= maxAttempts) {
459 + clearInterval(timer);
460 + return;
461 + }
462 + try {
463 + const sub = await BB.api.sync.subscriptionStatus();
464 + if (sub.active) {
465 + clearInterval(timer);
466 + BB.ui.showToast('Subscription activated! Sync is now enabled.', 'success');
467 + const banner = document.getElementById('sync-subscription-banner');
468 + if (banner) checkSubscriptionBanner(banner);
469 + }
470 + } catch (_) {
471 + // Ignore polling errors
472 + }
473 + }, 5000);
474 + }
475 +
500 476 // Listen for sync events to refresh feed list
501 477 if (window.__TAURI__?.event?.listen) {
502 478 window.__TAURI__.event.listen('sync:changes-applied', () => {
@@ -4,7 +4,7 @@
4 4 //! via OAuth2 PKCE flow, managing encryption, manual sync, and settings.
5 5
6 6 use super::error::ApiError;
7 - use crate::state::{self, AppState};
7 + use crate::state::AppState;
8 8 use crate::sync_service;
9 9 use serde::{Deserialize, Serialize};
10 10 use std::sync::Arc;
@@ -174,34 +174,17 @@ fn start_callback_server() -> Result<u16, ApiError> {
174 174
175 175 // ── Commands ──
176 176
177 - /// Validate an API key against the server. Returns the app name on success.
177 + /// Fetch available pricing tiers for this app (no auth required, uses API key).
178 178 #[tauri::command]
179 179 #[instrument(skip_all)]
180 - pub async fn sync_test_api_key(api_key: String) -> Result<String, ApiError> {
181 - let server_url = std::env::var("BB_SYNC_SERVER_URL")
182 - .unwrap_or_else(|_| state::SYNC_SERVER_URL.to_string());
183 - let app_name = synckit_client::validate_api_key(&server_url, &api_key)
184 - .await
185 - .map_err(|e| ApiError::internal(format!("Invalid API key: {}", e)))?;
186 - Ok(app_name)
187 - }
188 -
189 - /// Save an API key and create a SyncKit client. Returns true on success.
190 - #[tauri::command]
191 - #[instrument(skip_all)]
192 - pub async fn sync_save_api_key(
180 + pub async fn sync_get_tiers(
193 181 state: State<'_, Arc<AppState>>,
194 - api_key: String,
195 - ) -> Result<bool, ApiError> {
196 - state::save_api_key(&state.data_dir, &api_key);
197 - let server_url = std::env::var("BB_SYNC_SERVER_URL")
198 - .unwrap_or_else(|_| state::SYNC_SERVER_URL.to_string());
199 - let client = synckit_client::SyncKitClient::new(synckit_client::SyncKitConfig {
200 - server_url,
201 - api_key,
202 - });
203 - *state.sync_client.write() = Some(Arc::new(client));
204 - Ok(true)
182 + ) -> Result<Vec<synckit_client::TierInfo>, ApiError> {
183 + let client = require_sync_client(&state)?;
184 + let tiers = client.get_available_tiers()
185 + .await
186 + .map_err(|e| ApiError::internal(format!("Failed to fetch tiers: {e}")))?;
187 + Ok(tiers)
205 188 }
206 189
207 190 #[tauri::command]
@@ -99,8 +99,7 @@ pub fn build_app() -> tauri::Builder<tauri::Wry> {
99 99 commands::get_custom_themes_dir,
100 100 commands::import_theme,
101 101 commands::export_theme,
102 - commands::sync_test_api_key,
103 - commands::sync_save_api_key,
102 + commands::sync_get_tiers,
104 103 commands::sync_status,
105 104 commands::sync_start_auth,
106 105 commands::sync_complete_auth,
@@ -14,6 +14,9 @@ use tracing::{debug, error, info, warn};
14 14 /// Default sync server URL.
15 15 pub const SYNC_SERVER_URL: &str = "https://makenot.work";
16 16
17 + /// Embedded SyncKit API key, set at build time via SYNCKIT_API_KEY env var.
18 + pub const EMBEDDED_API_KEY: Option<&str> = option_env!("SYNCKIT_API_KEY");
19 +
17 20 /// Application state wrapping the Orchestrator
18 21 pub struct AppState {
19 22 pub orchestrator: Orchestrator,
@@ -193,7 +196,11 @@ pub fn load_api_key(data_dir: &std::path::Path) -> Option<String> {
193 196 return Some(key);
194 197 }
195 198 }
196 - std::env::var("BB_SYNC_API_KEY").ok()
199 + if let Ok(key) = std::env::var("BB_SYNC_API_KEY") {
200 + return Some(key);
201 + }
202 +
203 + EMBEDDED_API_KEY.map(String::from)
197 204 }
198 205
199 206 /// Save API key to the data directory.