···11+/**
22+ * Base service interface for OAuth integrations.
33+ *
44+ * All service modules follow this pattern. To add a new service:
55+ * 1. Create a new file in services/ that exports a class extending this pattern
66+ * 2. Define the OAuth config (authUrl, tokenUrl, scopes, clientId)
77+ * 3. Import and register in services/index.js
88+ *
99+ * Token storage uses api.settings.setKey() / getKey() with a per-service key.
1010+ */
1111+1212+const api = typeof window !== 'undefined' && window.app ? window.app : null;
1313+1414+/**
1515+ * Create a service definition object.
1616+ * Each service must provide:
1717+ * id - unique identifier (e.g., 'youtube')
1818+ * name - display name
1919+ * icon - emoji or short label for the card
2020+ * color - hex color for the card accent
2121+ * oauth - { authUrl, tokenUrl, clientId, scopes, extraParams? }
2222+ */
2323+export function createService({ id, name, icon, color, oauth }) {
2424+ const STORAGE_KEY = `wonderwall:${id}`;
2525+2626+ return {
2727+ id,
2828+ name,
2929+ icon,
3030+ color,
3131+ oauth,
3232+3333+ /** Retrieve stored token data (or null). */
3434+ async getToken() {
3535+ if (!api) return null;
3636+ try {
3737+ const result = await api.settings.getKey(STORAGE_KEY);
3838+ if (result.success && result.data) return result.data;
3939+ } catch (err) {
4040+ console.warn(`[wonderwall:${id}] Failed to load token:`, err);
4141+ }
4242+ return null;
4343+ },
4444+4545+ /** Persist token data. */
4646+ async setToken(tokenData) {
4747+ if (!api) return;
4848+ await api.settings.setKey(STORAGE_KEY, tokenData);
4949+ },
5050+5151+ /** Remove stored token data (disconnect). */
5252+ async clearToken() {
5353+ if (!api) return;
5454+ await api.settings.setKey(STORAGE_KEY, null);
5555+ },
5656+5757+ /** Check whether the service has a stored token. */
5858+ async isConnected() {
5959+ const token = await this.getToken();
6060+ return token !== null;
6161+ },
6262+6363+ /**
6464+ * Start the OAuth authorization flow.
6565+ * Uses the generic loopback server from the Electron backend.
6666+ *
6767+ * @returns {Promise<Object>} Token response data from the provider.
6868+ */
6969+ async connect() {
7070+ if (!api) throw new Error('Peek API not available');
7171+7272+ // 1. Start loopback server
7373+ const loopback = await api.oauth.startLoopback();
7474+ if (!loopback.success) {
7575+ throw new Error(loopback.error || 'Failed to start loopback server');
7676+ }
7777+ const port = loopback.port;
7878+ const redirectUri = `http://127.0.0.1:${port}/callback`;
7979+8080+ try {
8181+ // 2. Generate state for CSRF protection
8282+ const stateArray = new Uint8Array(16);
8383+ crypto.getRandomValues(stateArray);
8484+ const state = Array.from(stateArray, b => b.toString(16).padStart(2, '0')).join('');
8585+8686+ // 3. Build authorization URL
8787+ const params = new URLSearchParams({
8888+ client_id: oauth.clientId,
8989+ redirect_uri: redirectUri,
9090+ response_type: 'code',
9191+ scope: oauth.scopes,
9292+ state,
9393+ ...(oauth.extraParams || {})
9494+ });
9595+ const authUrl = `${oauth.authUrl}?${params.toString()}`;
9696+9797+ // 4. Open auth window
9898+ api.window.open(authUrl, {
9999+ width: 600,
100100+ height: 700,
101101+ role: 'modal',
102102+ title: `Connect ${name}`,
103103+ });
104104+105105+ // 5. Wait for callback
106106+ const callbackResult = await api.oauth.awaitCallback(port);
107107+ if (!callbackResult.success) {
108108+ throw new Error(callbackResult.error || 'OAuth callback failed');
109109+ }
110110+ const callbackParams = callbackResult.params;
111111+112112+ // 6. Verify state
113113+ if (callbackParams.state !== state) {
114114+ throw new Error('OAuth state mismatch');
115115+ }
116116+ if (callbackParams.error) {
117117+ throw new Error(callbackParams.error_description || callbackParams.error);
118118+ }
119119+ const code = callbackParams.code;
120120+ if (!code) throw new Error('No authorization code received');
121121+122122+ // 7. Exchange code for tokens
123123+ const tokenBody = new URLSearchParams({
124124+ grant_type: 'authorization_code',
125125+ code,
126126+ redirect_uri: redirectUri,
127127+ client_id: oauth.clientId,
128128+ });
129129+130130+ // Include client_secret if provided (some services require it)
131131+ if (oauth.clientSecret) {
132132+ tokenBody.set('client_secret', oauth.clientSecret);
133133+ }
134134+135135+ const tokenRes = await fetch(oauth.tokenUrl, {
136136+ method: 'POST',
137137+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
138138+ body: tokenBody.toString(),
139139+ });
140140+141141+ if (!tokenRes.ok) {
142142+ const errBody = await tokenRes.json().catch(() => ({}));
143143+ throw new Error(errBody.error_description || errBody.error || `Token exchange failed (${tokenRes.status})`);
144144+ }
145145+146146+ const tokenData = await tokenRes.json();
147147+148148+ // 8. Store the token
149149+ const stored = {
150150+ accessToken: tokenData.access_token,
151151+ refreshToken: tokenData.refresh_token || null,
152152+ expiresAt: tokenData.expires_in
153153+ ? Date.now() + tokenData.expires_in * 1000
154154+ : null,
155155+ scope: tokenData.scope || oauth.scopes,
156156+ connectedAt: Date.now(),
157157+ };
158158+159159+ await this.setToken(stored);
160160+ return stored;
161161+162162+ } catch (err) {
163163+ // Cancel the loopback server if still pending
164164+ try { await api.oauth.awaitCallback(port); } catch {}
165165+ throw err;
166166+ }
167167+ },
168168+169169+ /** Disconnect: remove stored tokens. */
170170+ async disconnect() {
171171+ await this.clearToken();
172172+ },
173173+ };
174174+}
+18
features/wonderwall/services/index.js
···11+/**
22+ * Service registry for Wonderwall.
33+ *
44+ * Import all service modules here. The home UI iterates this array
55+ * to render service cards and drive connect/disconnect flows.
66+ *
77+ * To add a new service:
88+ * 1. Create services/myservice.js using createService() from base.js
99+ * 2. Import it here and add to the array
1010+ */
1111+1212+import youtube from './youtube.js';
1313+import soundcloud from './soundcloud.js';
1414+import reddit from './reddit.js';
1515+1616+const services = [youtube, soundcloud, reddit];
1717+1818+export default services;
+29
features/wonderwall/services/reddit.js
···11+/**
22+ * Reddit OAuth service integration.
33+ *
44+ * To use: create an app at https://www.reddit.com/prefs/apps
55+ * (type: "installed app"), then replace the placeholder CLIENT_ID.
66+ *
77+ * Reddit uses a slightly different OAuth flow: duration=permanent for
88+ * refresh tokens, and the token endpoint requires Basic auth header
99+ * instead of client_secret in the body. The base service handles the
1010+ * code exchange, and the extraParams here configure the auth request.
1111+ */
1212+1313+import { createService } from './base.js';
1414+1515+export default createService({
1616+ id: 'reddit',
1717+ name: 'Reddit',
1818+ icon: '\u2B24', // filled circle
1919+ color: '#FF4500',
2020+ oauth: {
2121+ authUrl: 'https://www.reddit.com/api/v1/authorize',
2222+ tokenUrl: 'https://www.reddit.com/api/v1/access_token',
2323+ clientId: 'YOUR_REDDIT_CLIENT_ID',
2424+ scopes: 'identity read',
2525+ extraParams: {
2626+ duration: 'permanent',
2727+ },
2828+ },
2929+});