···57065706 }
57075707 });
5708570857095709- ipcMain.handle('feature-browse:list-by-publisher', async (_event, args: {
57105710- token: string;
57115711- did: string;
57125712- pdsUrl: string;
57135713- cursor?: string;
57145714- }) => {
57155715- const grant = getGrantForToken(args.token);
57165716- if (!grant) return { error: 'Invalid token' };
57175717-57185718- if (!args.did || !args.pdsUrl) {
57195719- return { error: 'DID and PDS URL are required' };
57205720- }
57215721-57225722- try {
57235723- const params = new URLSearchParams({
57245724- repo: args.did,
57255725- collection: 'space.peek.feature.release',
57265726- limit: '100',
57275727- });
57285728- if (args.cursor) {
57295729- params.set('cursor', args.cursor);
57305730- }
57315731-57325732- const url = `${args.pdsUrl}/xrpc/com.atproto.repo.listRecords?${params.toString()}`;
57335733- const res = await fetch(url);
57345734-57355735- if (!res.ok) {
57365736- const body = await res.text().catch(() => '');
57375737- return { error: `PDS returned ${res.status}: ${body}` };
57385738- }
57395739-57405740- const data = await res.json() as {
57415741- records: Array<{
57425742- uri: string;
57435743- cid: string;
57445744- value: Record<string, unknown>;
57455745- }>;
57465746- cursor?: string;
57475747- };
57485748-57495749- // Group records by featureId and pick latest version for each
57505750- const featureMap = new Map<string, {
57515751- uri: string;
57525752- featureId: string;
57535753- name: string;
57545754- version: string;
57555755- description?: string;
57565756- categories?: string[];
57575757- capabilities?: string[];
57585758- createdAt: string;
57595759- }>();
57605760-57615761- for (const rec of data.records || []) {
57625762- const val = rec.value as Record<string, unknown>;
57635763- const featureId = val.featureId as string;
57645764- if (!featureId) continue;
57655765-57665766- const existing = featureMap.get(featureId);
57675767- const createdAt = val.createdAt as string || '';
57685768-57695769- // Keep latest by createdAt
57705770- if (!existing || createdAt > existing.createdAt) {
57715771- featureMap.set(featureId, {
57725772- uri: rec.uri,
57735773- featureId,
57745774- name: (val.name as string) || featureId,
57755775- version: (val.version as string) || 'unknown',
57765776- description: val.description as string | undefined,
57775777- categories: val.categories as string[] | undefined,
57785778- capabilities: val.capabilities as string[] | undefined,
57795779- createdAt,
57805780- });
57815781- }
57825782- }
57835783-57845784- return {
57855785- records: Array.from(featureMap.values()),
57865786- cursor: data.cursor,
57875787- };
57885788- } catch (err) {
57895789- const message = err instanceof Error ? err.message : String(err);
57905790- return { error: `Failed to list records: ${message}` };
57915791- }
57925792- });
57935793-57945794- // ── Feature Publish ──
57955795-57965796- ipcMain.handle('feature-publish:list-local', async (_event, args: {
57975797- token: string;
57985798- }) => {
57995799- const grant = getGrantForToken(args.token);
58005800- if (!grant) return { error: 'Invalid token' };
58015801-58025802- const registry = getFeatureRegistry();
58035803- if (!registry) return { error: 'Feature registry not initialized' };
58045804-58055805- // Return all features (local and builtin) that could be published
58065806- const all = registry.listFeatures();
58075807- const publishable = all.filter(e =>
58085808- e.source.type === 'local' || e.source.type === 'builtin' || e.source.type === 'dev'
58095809- );
58105810-58115811- return {
58125812- features: publishable.map(e => ({
58135813- id: e.id,
58145814- name: e.name,
58155815- version: e.source.version || '0.0.0',
58165816- path: e.path,
58175817- sourceType: e.source.type,
58185818- description: (e as any).description || '',
58195819- })),
58205820- };
58215821- });
58225822-58235823- ipcMain.handle('feature-publish:read-feature-files', async (_event, args: {
58245824- token: string;
58255825- featureId: string;
58265826- }) => {
58275827- const grant = getGrantForToken(args.token);
58285828- if (!grant) return { error: 'Invalid token' };
58295829-58305830- const registry = getFeatureRegistry();
58315831- if (!registry) return { error: 'Feature registry not initialized' };
58325832-58335833- const entry = registry.getFeature(args.featureId);
58345834- if (!entry) return { error: `Feature not found: ${args.featureId}` };
58355835-58365836- try {
58375837- const featurePath = entry.path;
58385838- if (!fs.existsSync(featurePath)) {
58395839- return { error: `Feature directory not found: ${featurePath}` };
58405840- }
58415841-58425842- const files: Array<{
58435843- relativePath: string;
58445844- content: string; // base64
58455845- size: number;
58465846- mimeType: string;
58475847- }> = [];
58485848-58495849- function readDir(dirPath: string, prefix: string): void {
58505850- const entries = fs.readdirSync(dirPath, { withFileTypes: true });
58515851- for (const dirent of entries) {
58525852- // Skip hidden files/dirs, node_modules, .git
58535853- if (dirent.name.startsWith('.') || dirent.name === 'node_modules') continue;
58545854-58555855- const fullPath = path.join(dirPath, dirent.name);
58565856- const relativePath = prefix ? `${prefix}/${dirent.name}` : dirent.name;
58575857-58585858- if (dirent.isDirectory()) {
58595859- readDir(fullPath, relativePath);
58605860- } else if (dirent.isFile()) {
58615861- const data = fs.readFileSync(fullPath);
58625862- const ext = path.extname(dirent.name).toLowerCase();
58635863- const mimeType = getMimeType(ext);
58645864-58655865- files.push({
58665866- relativePath,
58675867- content: data.toString('base64'),
58685868- size: data.length,
58695869- mimeType,
58705870- });
58715871- }
58725872- }
58735873- }
58745874-58755875- readDir(featurePath, '');
58765876-58775877- return {
58785878- featureId: args.featureId,
58795879- name: entry.name,
58805880- path: featurePath,
58815881- files,
58825882- totalSize: files.reduce((sum, f) => sum + f.size, 0),
58835883- };
58845884- } catch (err) {
58855885- const message = err instanceof Error ? err.message : String(err);
58865886- return { error: `Failed to read feature files: ${message}` };
58875887- }
58885888- });
58895889-58905890- // NOTE: `feature-publish:upload-blob` and `feature-publish:create-record` IPC
58915891- // handlers have been removed. The publish flow now calls lex's renderer-side
58925892- // atproto helpers (uploadBlob / xrpcPost from peek://lex/atproto.js)
58935893- // directly so that DPoP signing happens in the renderer via Web Crypto —
58945894- // no backend DPoP implementation is needed.
58955895-58965896- // ── Feature Update ──
58975897-58985898- ipcMain.handle('feature-update:check-all', async (_event, args: {
58995899- token: string;
59005900- }) => {
59015901- const grant = getGrantForToken(args.token);
59025902- if (!grant) return { error: 'Invalid token' };
59035903-59045904- const registry = getFeatureRegistry();
59055905- if (!registry) return { error: 'Feature registry not initialized' };
59065906-59075907- const source = getAtprotoSource();
59085908- if (!source) return { error: 'AtprotoSource not initialized' };
59095909-59105910- try {
59115911- // Get all atproto-sourced features
59125912- const all = registry.listFeatures();
59135913- const atprotoFeatures = all.filter(e => e.source.type === 'atproto' && e.source.publisher);
59145914-59155915- if (atprotoFeatures.length === 0) {
59165916- return { updates: [] };
59175917- }
59185918-59195919- // Group by publisher DID
59205920- const byPublisher = new Map<string, typeof atprotoFeatures>();
59215921- for (const feature of atprotoFeatures) {
59225922- const did = feature.source.publisher!;
59235923- if (!byPublisher.has(did)) {
59245924- byPublisher.set(did, []);
59255925- }
59265926- byPublisher.get(did)!.push(feature);
59275927- }
59285928-59295929- const updates: Array<{
59305930- id: string;
59315931- name: string;
59325932- currentVersion: string;
59335933- availableVersion: string;
59345934- atUri: string;
59355935- }> = [];
59365936-59375937- for (const [did, pubFeatures] of byPublisher) {
59385938- try {
59395939- // Resolve publisher
59405940- const { resolveDid: resolveDidFn } = await import('./atproto-source.js');
59415941- const identity = await resolveDidFn(did);
59425942- if (!identity) continue;
59435943-59445944- // List release records
59455945- const params = new URLSearchParams({
59465946- repo: did,
59475947- collection: 'space.peek.feature.release',
59485948- limit: '100',
59495949- });
59505950- const url = `${identity.pdsUrl}/xrpc/com.atproto.repo.listRecords?${params.toString()}`;
59515951- const res = await fetch(url);
59525952- if (!res.ok) continue;
59535953-59545954- const data = await res.json() as {
59555955- records: Array<{ uri: string; value: Record<string, unknown> }>;
59565956- };
59575957-59585958- // Build featureId -> latest version map
59595959- const featureMap = new Map<string, { uri: string; version: string; createdAt: string }>();
59605960- for (const rec of data.records || []) {
59615961- const val = rec.value;
59625962- const featureId = val.featureId as string;
59635963- if (!featureId) continue;
59645964- const existing = featureMap.get(featureId);
59655965- const createdAt = (val.createdAt as string) || '';
59665966- if (!existing || createdAt > existing.createdAt) {
59675967- featureMap.set(featureId, {
59685968- uri: rec.uri,
59695969- version: (val.version as string) || '0.0.0',
59705970- createdAt,
59715971- });
59725972- }
59735973- }
59745974-59755975- // Compare versions
59765976- for (const feature of pubFeatures) {
59775977- const policy = feature.updatePolicy || 'auto';
59785978- if (policy === 'pinned') continue;
59795979-59805980- const currentVersion = feature.source.version || '0.0.0';
59815981- const latest = featureMap.get(feature.id);
59825982- if (!latest) continue;
59835983-59845984- if (compareSemverSimple(latest.version, currentVersion) > 0) {
59855985- // Update availableVersion in registry
59865986- const entry = registry.getFeature(feature.id);
59875987- if (entry) {
59885988- entry.availableVersion = latest.version;
59895989- entry.lastUpdateCheck = new Date().toISOString();
59905990- registry.registerFeature(entry);
59915991- }
59925992-59935993- updates.push({
59945994- id: feature.id,
59955995- name: feature.name,
59965996- currentVersion,
59975997- availableVersion: latest.version,
59985998- atUri: latest.uri,
59995999- });
60006000- } else {
60016001- // No update — clear availableVersion, update lastUpdateCheck
60026002- const entry = registry.getFeature(feature.id);
60036003- if (entry) {
60046004- entry.availableVersion = undefined;
60056005- entry.lastUpdateCheck = new Date().toISOString();
60066006- registry.registerFeature(entry);
60076007- }
60086008- }
60096009- }
60106010- } catch (err) {
60116011- console.error(`[tile-ipc] Update check failed for publisher ${did}:`, err);
60126012- }
60136013- }
60146014-60156015- registry.saveRegistry();
60166016- return { updates };
60176017- } catch (err) {
60186018- const message = err instanceof Error ? err.message : String(err);
60196019- return { error: `Update check failed: ${message}` };
60206020- }
60216021- });
60226022-60236023- ipcMain.handle('feature-update:check-one', async (_event, args: {
60246024- token: string;
60256025- id: string;
60266026- }) => {
60276027- const grant = getGrantForToken(args.token);
60286028- if (!grant) return { error: 'Invalid token' };
60296029-60306030- const registry = getFeatureRegistry();
60316031- if (!registry) return { error: 'Feature registry not initialized' };
60326032-60336033- const entry = registry.getFeature(args.id);
60346034- if (!entry) return { error: `Feature not found: ${args.id}` };
60356035- if (entry.source.type !== 'atproto' || !entry.source.publisher) {
60366036- return { error: 'Feature is not from AT Protocol' };
60376037- }
60386038-60396039- try {
60406040- const { resolveDid: resolveDidFn } = await import('./atproto-source.js');
60416041- const identity = await resolveDidFn(entry.source.publisher);
60426042- if (!identity) return { error: `Could not resolve publisher: ${entry.source.publisher}` };
60436043-60446044- const params = new URLSearchParams({
60456045- repo: entry.source.publisher,
60466046- collection: 'space.peek.feature.release',
60476047- limit: '100',
60486048- });
60496049- const url = `${identity.pdsUrl}/xrpc/com.atproto.repo.listRecords?${params.toString()}`;
60506050- const res = await fetch(url);
60516051- if (!res.ok) return { error: `PDS returned ${res.status}` };
60526052-60536053- const data = await res.json() as {
60546054- records: Array<{ uri: string; value: Record<string, unknown> }>;
60556055- };
60566056-60576057- // Find latest version for this featureId
60586058- let latestUri = '';
60596059- let latestVersion = '0.0.0';
60606060- let latestCreatedAt = '';
60616061-60626062- for (const rec of data.records || []) {
60636063- const val = rec.value;
60646064- if ((val.featureId as string) !== entry.id) continue;
60656065- const createdAt = (val.createdAt as string) || '';
60666066- if (createdAt > latestCreatedAt) {
60676067- latestUri = rec.uri;
60686068- latestVersion = (val.version as string) || '0.0.0';
60696069- latestCreatedAt = createdAt;
60706070- }
60716071- }
60726072-60736073- const currentVersion = entry.source.version || '0.0.0';
60746074- entry.lastUpdateCheck = new Date().toISOString();
60756075-60766076- if (compareSemverSimple(latestVersion, currentVersion) > 0) {
60776077- entry.availableVersion = latestVersion;
60786078- registry.registerFeature(entry);
60796079- registry.saveRegistry();
60806080- return {
60816081- hasUpdate: true,
60826082- currentVersion,
60836083- availableVersion: latestVersion,
60846084- atUri: latestUri,
60856085- };
60866086- }
60876087-60886088- entry.availableVersion = undefined;
60896089- registry.registerFeature(entry);
60906090- registry.saveRegistry();
60916091- return { hasUpdate: false, currentVersion };
60926092- } catch (err) {
60936093- const message = err instanceof Error ? err.message : String(err);
60946094- return { error: `Update check failed: ${message}` };
60956095- }
60966096- });
60976097-60986098- ipcMain.handle('feature-update:apply', async (_event, args: {
60996099- token: string;
61006100- id: string;
61016101- userApproved?: boolean;
61026102- }) => {
61036103- const grant = getGrantForToken(args.token);
61046104- if (!grant) return { error: 'Invalid token' };
61056105-61066106- const registry = getFeatureRegistry();
61076107- if (!registry) return { error: 'Feature registry not initialized' };
61086108-61096109- const source = getAtprotoSource();
61106110- if (!source) return { error: 'AtprotoSource not initialized' };
61116111-61126112- const entry = registry.getFeature(args.id);
61136113- if (!entry) return { error: `Feature not found: ${args.id}` };
61146114- if (entry.source.type !== 'atproto' || !entry.source.uri) {
61156115- return { error: 'Feature is not from AT Protocol' };
61166116- }
61176117-61186118- try {
61196119- // Resolve to get latest metadata
61206120- const metadata = await source.resolve(entry.source.uri);
61216121- const newManifest = metadata.manifest;
61226122- const newVersion = metadata.source.version || newManifest.version;
61236123- const currentVersion = entry.source.version || '0.0.0';
61246124-61256125- // Validate there is actually a newer version
61266126- if (compareSemverSimple(newVersion || '0.0.0', currentVersion) <= 0) {
61276127- return { error: 'No newer version available' };
61286128- }
61296129-61306130- // Check for capability expansion
61316131- const capComparison = compareCapabilitiesSimple(
61326132- entry.grantedCapabilities,
61336133- newManifest.capabilities
61346134- );
61356135-61366136- if (capComparison.expanded && !args.userApproved) {
61376137- return {
61386138- error: 'Capabilities expanded — user approval required',
61396139- capabilitiesChanged: true,
61406140- added: capComparison.added,
61416141- removed: capComparison.removed,
61426142- };
61436143- }
61446144-61456145- // Fetch and install
61466146- const bundle = await source.fetch(entry.source.uri);
61476147- const installResult = installFromBundle(
61486148- bundle,
61496149- metadata.source,
61506150- registry,
61516151- { force: true, userApproved: args.userApproved || !capComparison.expanded }
61526152- );
61536153-61546154- if (!installResult.success) {
61556155- // Log failure
61566156- writeFeatureHistory(
61576157- args.id, 'update-failed', newVersion,
61586158- 'atproto', entry.source.uri, entry.source.publisher
61596159- );
61606160- return { error: installResult.error };
61616161- }
61626162-61636163- // Clear availableVersion
61646164- const updatedEntry = registry.getFeature(args.id);
61656165- if (updatedEntry) {
61666166- updatedEntry.availableVersion = undefined;
61676167- updatedEntry.lastUpdateCheck = new Date().toISOString();
61686168- registry.registerFeature(updatedEntry);
61696169- }
61706170-61716171- registry.saveRegistry();
61726172-61736173- // Write history
61746174- writeFeatureHistory(
61756175- args.id, 'update', newVersion,
61766176- 'atproto', entry.source.uri, entry.source.publisher,
61776177- installResult.entry?.grantedCapabilities as unknown as Record<string, unknown>
61786178- );
61796179-61806180- // Publish event
61816181- publish('feature-registry', 'feature:updated', {
61826182- id: args.id,
61836183- name: entry.name,
61846184- previousVersion: currentVersion,
61856185- newVersion,
61866186- });
61876187-61886188- return { success: true, entry: installResult.entry };
61896189- } catch (err) {
61906190- const message = err instanceof Error ? err.message : String(err);
61916191- writeFeatureHistory(
61926192- args.id, 'update-failed', undefined,
61936193- 'atproto', entry.source.uri, entry.source.publisher
61946194- );
61956195- return { error: `Update failed: ${message}` };
61966196- }
61976197- });
61986198-61996199- ipcMain.handle('feature-update:set-policy', async (_event, args: {
62006200- token: string;
62016201- id: string;
62026202- policy: 'auto' | 'notify' | 'pinned';
62036203- }) => {
62046204- const grant = getGrantForToken(args.token);
62056205- if (!grant) return { error: 'Invalid token' };
62066206-62076207- const registry = getFeatureRegistry();
62086208- if (!registry) return { error: 'Feature registry not initialized' };
62096209-62106210- const entry = registry.getFeature(args.id);
62116211- if (!entry) return { error: `Feature not found: ${args.id}` };
62126212-62136213- const validPolicies = ['auto', 'notify', 'pinned'];
62146214- if (!validPolicies.includes(args.policy)) {
62156215- return { error: `Invalid policy: ${args.policy}` };
62166216- }
62176217-62186218- entry.updatePolicy = args.policy;
62196219-62206220- if (args.policy === 'pinned') {
62216221- entry.pinnedVersion = entry.source.version;
62226222- } else {
62236223- entry.pinnedVersion = undefined;
62246224- }
62256225-62266226- registry.registerFeature(entry);
62276227- registry.saveRegistry();
62286228-62296229- return { success: true, policy: entry.updatePolicy, pinnedVersion: entry.pinnedVersion };
62306230- });
62316231-62326232- // ── Feature Dev Workflow ──
62336233-62346234- ipcMain.handle('feature-dev:pick-directory', async () => {
62356235- try {
62366236- const result = await dialog.showOpenDialog({
62376237- properties: ['openDirectory', 'createDirectory'],
62386238- title: 'Choose directory for new feature',
62396239- });
62406240- if (result.canceled || result.filePaths.length === 0) {
62416241- return { canceled: true };
62426242- }
62436243- return { path: result.filePaths[0] };
62446244- } catch (err) {
62456245- const message = err instanceof Error ? err.message : String(err);
62466246- return { error: `Failed to open directory picker: ${message}` };
62476247- }
62486248- });
62496249-62506250- ipcMain.handle('feature-dev:create', async (_event, args: {
62516251- token: string;
62526252- directoryPath: string;
62536253- featureId: string;
62546254- featureName: string;
62556255- }) => {
62566256- const grant = getGrantForToken(args.token);
62576257- if (!grant) return { error: 'Invalid token' };
62586258-62596259- const registry = getFeatureRegistry();
62606260- if (!registry) return { error: 'Feature registry not initialized' };
62616261-62626262- // Validate featureId: lowercase, alphanumeric + hyphens, no spaces
62636263- const idRegex = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
62646264- if (!args.featureId || args.featureId.length < 2 || !idRegex.test(args.featureId)) {
62656265- return { error: 'Feature ID must be lowercase alphanumeric with hyphens (min 2 chars, no leading/trailing hyphens)' };
62666266- }
62676267-62686268- if (!args.featureName || !args.featureName.trim()) {
62696269- return { error: 'Feature name is required' };
62706270- }
62716271-62726272- // Check the parent directory exists
62736273- const parentDir = path.dirname(args.directoryPath);
62746274- if (!fs.existsSync(parentDir)) {
62756275- return { error: `Parent directory does not exist: ${parentDir}` };
62766276- }
62776277-62786278- // Check for conflicts in registry
62796279- const existing = registry.getFeature(args.featureId);
62806280- if (existing) {
62816281- return { error: `Feature "${args.featureId}" is already registered in the registry` };
62826282- }
62836283-62846284- try {
62856285- // Create directory if it doesn't exist
62866286- if (!fs.existsSync(args.directoryPath)) {
62876287- fs.mkdirSync(args.directoryPath, { recursive: true });
62886288- }
62896289-62906290- const featureId = args.featureId;
62916291- const featureName = args.featureName.trim();
62926292-62936293- // Write scaffold files
62946294- const manifestContent = JSON.stringify({
62956295- manifestVersion: 3,
62966296- id: featureId,
62976297- name: featureName,
62986298- version: '0.1.0',
62996299- description: '',
63006300- tiles: [
63016301- {
63026302- id: 'background',
63036303- url: 'background.html',
63046304- lazy: true,
63056305- },
63066306- {
63076307- id: 'home',
63086308- url: 'home.html',
63096309- width: 800,
63106310- height: 600,
63116311- title: featureName,
63126312- },
63136313- ],
63146314- capabilities: {
63156315- pubsub: { scopes: ['self', 'global'] },
63166316- commands: true,
63176317- },
63186318- commands: [
63196319- {
63206320- name: featureId,
63216321- description: `Open ${featureName}`,
63226322- action: { type: 'window', url: 'home.html' },
63236323- },
63246324- ],
63256325- }, null, 2);
63266326-63276327- const backgroundHtml = `<!DOCTYPE html>
63286328-<html>
63296329-<head>
63306330- <meta charset="UTF-8">
63316331- <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';">
63326332- <title>${featureName} Background</title>
63336333-</head>
63346334-<body>
63356335-<script type="module">
63366336- import { init, uninit } from './background.js';
63376337-63386338- const api = window.app;
63396339- const hasPeekAPI = !!api;
63406340-63416341- if (hasPeekAPI && typeof api.initialize === 'function') {
63426342- // V2 Tile Runtime
63436343- if (api.pubsub && !api.publish) {
63446344- api.publish = api.pubsub.publish;
63456345- api.subscribe = api.pubsub.subscribe;
63466346- }
63476347-63486348- await api.initialize();
63496349-63506350- if (init) await init();
63516351-63526352- api.onShutdown(() => {
63536353- if (uninit) uninit();
63546354- });
63556355- } else if (hasPeekAPI) {
63566356- // V1 Legacy Runtime
63576357- if (init) await init();
63586358-63596359- api.publish('ext:ready', {
63606360- id: '${featureId}',
63616361- registeredTopics: Object.keys(window._cmdHandlers || {}).map(n => \`cmd:execute:\${n}\`),
63626362- manifest: { id: '${featureId}', version: '0.1.0' }
63636363- });
63646364-63656365- api.subscribe('app:shutdown', () => {
63666366- if (uninit) uninit();
63676367- });
63686368- }
63696369-<${'/'}script>
63706370-</body>
63716371-</html>`;
63726372-63736373- const backgroundJs = `/**
63746374- * ${featureName} — Background script
63756375- *
63766376- * This runs in a hidden background context. Use it for:
63776377- * - Registering commands
63786378- * - Subscribing to events
63796379- * - Background processing
63806380- *
63816381- * Export init() to run setup, uninit() for cleanup.
63826382- */
63836383-63846384-const api = window.app;
63856385-63866386-export async function init() {
63876387- console.log('[${featureId}] Background initialized');
63886388-63896389- // Register commands, subscribe to events, etc.
63906390- // Example:
63916391- // api.commands.register({
63926392- // name: '${featureId}',
63936393- // description: 'Open ${featureName}',
63946394- // execute: () => {
63956395- // api.window.open('peek://${featureId}/home.html', {
63966396- // key: '${featureId}-home',
63976397- // width: 800,
63986398- // height: 600,
63996399- // title: '${featureName}'
64006400- // });
64016401- // }
64026402- // });
64036403-}
64046404-64056405-export function uninit() {
64066406- console.log('[${featureId}] Background shutdown');
64076407-}
64086408-`;
64096409-64106410- const homeHtml = `<!DOCTYPE html>
64116411-<html>
64126412-<head>
64136413- <meta charset="UTF-8">
64146414- <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';">
64156415- <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1">
64166416- <title>${featureName}</title>
64176417- <link rel="stylesheet" type="text/css" href="home.css">
64186418-</head>
64196419-<body>
64206420- <header class="header">
64216421- <h1 class="header-title">${featureName}</h1>
64226422- </header>
64236423-64246424- <main class="content">
64256425- <div class="welcome">
64266426- <h2>Welcome to ${featureName}</h2>
64276427- <p>Edit <code>home.html</code>, <code>home.js</code>, and <code>home.css</code> to build your feature UI.</p>
64286428- <p>Use the Reload button in the Features Manager to see changes without restarting.</p>
64296429- </div>
64306430- </main>
64316431-64326432- <script>
64336433- // V2 tile runtime compat shims
64346434- (function() {
64356435- const api = window.app;
64366436- if (!api) return;
64376437- const isTileV2 = typeof api.initialize === 'function';
64386438- if (!isTileV2) return;
64396439- if (api.pubsub && !api.publish) {
64406440- api.publish = api.pubsub.publish;
64416441- api.subscribe = api.pubsub.subscribe;
64426442- }
64436443- })();
64446444- <${'/'}script>
64456445- <script type="module" src="home.js"><${'/'}script>
64466446-</body>
64476447-</html>`;
64486448-64496449- const homeJs = `/**
64506450- * ${featureName} — Home page script
64516451- *
64526452- * This is the main UI for your feature.
64536453- * The Peek API is available at window.app
64546454- */
64556455-64566456-const api = window.app;
64576457-const hasPeekAPI = !!api;
64586458-64596459-async function init() {
64606460- if (hasPeekAPI && typeof api.initialize === 'function') {
64616461- await api.initialize();
64626462- }
64636463-64646464- console.log('[${featureId}] Home page loaded');
64656465-64666466- // Your UI logic here
64676467-}
64686468-64696469-init();
64706470-`;
64716471-64726472- const homeCss = `/* Import Peek theme variables */
64736473-@import url('peek://theme/variables.css');
64746474-64756475-* {
64766476- box-sizing: border-box;
64776477- margin: 0;
64786478- padding: 0;
64796479-}
64806480-64816481-html {
64826482- font-family: var(--theme-font-sans);
64836483- -webkit-font-smoothing: antialiased;
64846484- font-size: 14px;
64856485- line-height: 1.5;
64866486-}
64876487-64886488-body {
64896489- background: var(--base00);
64906490- color: var(--base05);
64916491- min-height: 100vh;
64926492- display: flex;
64936493- flex-direction: column;
64946494-}
64956495-64966496-.header {
64976497- display: flex;
64986498- align-items: center;
64996499- padding: 10px 16px;
65006500- border-bottom: 1px solid var(--base02);
65016501- -webkit-app-region: drag;
65026502-}
65036503-65046504-.header-title {
65056505- font-size: 15px;
65066506- font-weight: 600;
65076507- color: var(--base05);
65086508-}
65096509-65106510-.content {
65116511- flex: 1;
65126512- padding: 24px;
65136513- overflow-y: auto;
65146514-}
65156515-65166516-.welcome {
65176517- max-width: 480px;
65186518- margin: 40px auto;
65196519- text-align: center;
65206520-}
65216521-65226522-.welcome h2 {
65236523- font-size: 18px;
65246524- font-weight: 600;
65256525- color: var(--base05);
65266526- margin-bottom: 12px;
65276527-}
65286528-65296529-.welcome p {
65306530- font-size: 13px;
65316531- color: var(--base04);
65326532- margin-bottom: 8px;
65336533-}
65346534-65356535-.welcome code {
65366536- background: var(--base01);
65376537- border: 1px solid var(--base02);
65386538- border-radius: 3px;
65396539- padding: 1px 4px;
65406540- font-family: var(--theme-font-mono, monospace);
65416541- font-size: 12px;
65426542-}
65436543-`;
65446544-65456545- // Write all scaffold files
65466546- fs.writeFileSync(path.join(args.directoryPath, 'manifest.json'), manifestContent, 'utf-8');
65476547- fs.writeFileSync(path.join(args.directoryPath, 'background.html'), backgroundHtml, 'utf-8');
65486548- fs.writeFileSync(path.join(args.directoryPath, 'background.js'), backgroundJs, 'utf-8');
65496549- fs.writeFileSync(path.join(args.directoryPath, 'home.html'), homeHtml, 'utf-8');
65506550- fs.writeFileSync(path.join(args.directoryPath, 'home.js'), homeJs, 'utf-8');
65516551- fs.writeFileSync(path.join(args.directoryPath, 'home.css'), homeCss, 'utf-8');
65526552-65536553- // Resolve capabilities (dev = all granted, like builtin)
65546554- const manifestParsed = JSON.parse(manifestContent);
65556555- const capGrant = resolveCapabilities(featureId, manifestParsed.capabilities, true);
65566556-65576557- // Register in feature registry
65586558- const entry = {
65596559- id: featureId,
65606560- name: featureName,
65616561- path: args.directoryPath,
65626562- source: {
65636563- type: 'dev' as const,
65646564- version: '0.1.0',
65656565- },
65666566- grantedCapabilities: capGrant.capabilities,
65676567- userApproved: true,
65686568- installedAt: new Date().toISOString(),
65696569- };
65706570-65716571- registry.registerFeature(entry);
65726572- registry.saveRegistry();
65736573-65746574- // Write history event
65756575- writeFeatureHistory(featureId, 'install', '0.1.0', 'dev');
65766576-65776577- // Publish installed event
65786578- publish('feature-registry', 'feature:installed', {
65796579- id: featureId,
65806580- name: featureName,
65816581- source: { type: 'dev' },
65826582- });
65836583-65846584- return { success: true, entry };
65856585- } catch (err) {
65866586- const message = err instanceof Error ? err.message : String(err);
65876587- return { error: `Failed to create feature: ${message}` };
65886588- }
65896589- });
65906590-65916591- ipcMain.handle('feature-dev:reload', async (_event, args: {
65926592- token: string;
65936593- featureId: string;
65946594- }) => {
65956595- const grant = getGrantForToken(args.token);
65966596- if (!grant) return { error: 'Invalid token' };
65976597-65986598- const registry = getFeatureRegistry();
65996599- if (!registry) return { error: 'Feature registry not initialized' };
66006600-66016601- const entry = registry.getFeature(args.featureId);
66026602- if (!entry) return { error: `Feature not found: ${args.featureId}` };
66036603-66046604- try {
66056605- // Strategy: find any webContents whose URL starts with peek://{featureId}/
66066606- const urlPrefix = `peek://${args.featureId}/`;
66076607- const allContents = webContents.getAllWebContents();
66086608- let reloaded = 0;
66096609-66106610- for (const wc of allContents) {
66116611- const url = wc.getURL();
66126612- if (url.startsWith(urlPrefix)) {
66136613- wc.reload();
66146614- reloaded++;
66156615- continue;
66166616- }
66176617-66186618- // Also check frames in extension host (iframes)
66196619- try {
66206620- const mainFrame = wc.mainFrame;
66216621- for (const frame of mainFrame.framesInSubtree) {
66226622- if (frame !== mainFrame && frame.url && frame.url.startsWith(urlPrefix)) {
66236623- // Reload the iframe by navigating it again
66246624- frame.executeJavaScript('location.reload()');
66256625- reloaded++;
66266626- }
66276627- }
66286628- } catch {
66296629- // Some webContents may not have frames accessible
66306630- }
66316631- }
66326632-66336633- // Also try the tile launcher's tracked windows
66346634- const manifest = entry.path ? JSON.parse(
66356635- fs.readFileSync(path.join(entry.path, 'manifest.json'), 'utf-8')
66366636- ) : null;
66376637-66386638- if (manifest && manifest.tiles) {
66396639- for (const tile of manifest.tiles) {
66406640- const win = getTileWindow(args.featureId, tile.id);
66416641- if (win && !win.isDestroyed()) {
66426642- win.webContents.reload();
66436643- reloaded++;
66446644- }
66456645- }
66466646- }
66476647-66486648- if (reloaded === 0) {
66496649- return { success: true, message: 'No active tiles found to reload. Feature will load on next launch.' };
66506650- }
66516651-66526652- return { success: true, message: `Reloaded ${reloaded} tile(s)` };
66536653- } catch (err) {
66546654- const message = err instanceof Error ? err.message : String(err);
66556655- return { error: `Failed to reload feature: ${message}` };
66566656- }
66576657- });
66586658-66596659- ipcMain.handle('feature-dev:open-devtools', async (_event, args: {
66606660- token: string;
66616661- featureId: string;
66626662- }) => {
66636663- const grant = getGrantForToken(args.token);
66646664- if (!grant) return { error: 'Invalid token' };
66656665-66666666- try {
66676667- const urlPrefix = `peek://${args.featureId}/`;
66686668- const allContents = webContents.getAllWebContents();
66696669- let opened = 0;
66706670-66716671- // First try direct BrowserWindows with matching URLs
66726672- for (const wc of allContents) {
66736673- const url = wc.getURL();
66746674- if (url.startsWith(urlPrefix)) {
66756675- wc.openDevTools();
66766676- opened++;
66776677- }
66786678- }
66796679-66806680- // If no direct match, check extension host frames
66816681- if (opened === 0) {
66826682- for (const wc of allContents) {
66836683- try {
66846684- const mainFrame = wc.mainFrame;
66856685- for (const frame of mainFrame.framesInSubtree) {
66866686- if (frame !== mainFrame && frame.url && frame.url.startsWith(urlPrefix)) {
66876687- // For iframes in extension host, open devtools on the host window
66886688- // (Electron doesn't support opening devtools on individual frames)
66896689- wc.openDevTools();
66906690- opened++;
66916691- break;
66926692- }
66936693- }
66946694- } catch {
66956695- // Skip inaccessible webContents
66966696- }
66976697- if (opened > 0) break;
66986698- }
66996699- }
67006700-67016701- if (opened === 0) {
67026702- return { error: 'No active tiles found. The feature may not be loaded yet.' };
67036703- }
67046704-67056705- return { success: true, message: `Opened DevTools for ${opened} context(s)` };
67066706- } catch (err) {
67076707- const message = err instanceof Error ? err.message : String(err);
67086708- return { error: `Failed to open DevTools: ${message}` };
67096709- }
67106710- });
67116711-67126712- // ── feature-devtools:open ──
67136713- //
67146714- // Opens DevTools for ANY tile feature (installed or dev-source), unlike
67156715- // `feature-dev:open-devtools` which is token-gated and primarily used for
67166716- // dev-source features. DevTools is a developer affordance — no capability
67176717- // check required, but we gate by caller origin so only privileged UIs
67186718- // (the main app, features-manager, etc.) can open devtools.
67196719- //
67206720- // Args: { featureId: string, entryId?: string }
67216721- // - featureId — the tile id
67226722- // - entryId — which tile entry (defaults to 'background')
67236723- ipcMain.handle('feature-devtools:open', async (ev, args: {
67246724- featureId: string;
67256725- entryId?: string;
67266726- }) => {
67276727- // Origin gate: only allow from privileged callers. The main app window
67286728- // and all builtin peek:// surfaces are trusted. External web content
67296729- // should never reach here (they can't reach ipcMain anyway), but this
67306730- // is a cheap belt-and-suspenders check.
67316731- try {
67326732- const senderUrl = ev.sender.getURL();
67336733- if (!senderUrl.startsWith('peek://')) {
67346734- return { error: 'Unauthorized origin' };
67356735- }
67366736- } catch {
67376737- return { error: 'Unable to verify caller origin' };
67386738- }
67396739-67406740- if (!args || !args.featureId) {
67416741- return { error: 'featureId is required' };
67426742- }
67436743-67446744- const entryId = args.entryId || 'background';
67456745-67466746- try {
67476747- // First try the tile launcher's tracked windows (v2 tiles — installed or dev).
67486748- const win = getTileWindow(args.featureId, entryId);
67496749- if (win && !win.isDestroyed()) {
67506750- win.webContents.openDevTools({ mode: 'detach' });
67516751- return { success: true, message: `Opened DevTools for ${args.featureId}:${entryId}` };
67526752- }
67536753-67546754- // Fallback: scan webContents for any window whose URL belongs to this
67556755- // tile. Handles cases where the tile is running in a window we don't
67566756- // track (e.g. extension host iframe, or a window entry not recorded
67576757- // in the launcher map).
67586758- const urlPrefix = `peek://${args.featureId}/`;
67596759- const allContents = webContents.getAllWebContents();
67606760- let opened = 0;
67616761- for (const wc of allContents) {
67626762- if (wc.getURL().startsWith(urlPrefix)) {
67636763- wc.openDevTools({ mode: 'detach' });
67646764- opened++;
67656765- }
67666766- }
67676767-67686768- // Also scan extension host iframes — same pattern as feature-dev:open-devtools.
67696769- if (opened === 0) {
67706770- for (const wc of allContents) {
67716771- try {
67726772- const mainFrame = wc.mainFrame;
67736773- for (const frame of mainFrame.framesInSubtree) {
67746774- if (frame !== mainFrame && frame.url && frame.url.startsWith(urlPrefix)) {
67756775- wc.openDevTools({ mode: 'detach' });
67766776- opened++;
67776777- break;
67786778- }
67796779- }
67806780- } catch {
67816781- // Skip inaccessible webContents
67826782- }
67836783- if (opened > 0) break;
67846784- }
67856785- }
67866786-67876787- if (opened === 0) {
67886788- return { error: `No active tile found for ${args.featureId}:${entryId}. The feature may not be loaded yet.` };
67896789- }
67906790-67916791- return { success: true, message: `Opened DevTools for ${opened} context(s)` };
67926792- } catch (err) {
67936793- const message = err instanceof Error ? err.message : String(err);
67946794- return { error: `Failed to open DevTools: ${message}` };
67956795- }
67966796- });
67976797-67986798- ipcMain.handle('feature-dev:validate', async (_event, args: {
67996799- token: string;
68006800- featureId: string;
68016801- }) => {
68026802- const grant = getGrantForToken(args.token);
68036803- if (!grant) return { error: 'Invalid token' };
68046804-68056805- const registry = getFeatureRegistry();
68066806- if (!registry) return { error: 'Feature registry not initialized' };
68076807-68086808- const entry = registry.getFeature(args.featureId);
68096809- if (!entry) return { error: `Feature not found: ${args.featureId}` };
68106810-68116811- const errors: string[] = [];
68126812- const warnings: string[] = [];
68136813-68146814- try {
68156815- const featurePath = entry.path;
68166816-68176817- // Check directory exists
68186818- if (!fs.existsSync(featurePath)) {
68196819- return { valid: false, errors: [`Feature directory not found: ${featurePath}`], warnings: [] };
68206820- }
68216821-68226822- // Read and validate manifest
68236823- const manifestPath = path.join(featurePath, 'manifest.json');
68246824- if (!fs.existsSync(manifestPath)) {
68256825- return { valid: false, errors: ['manifest.json not found'], warnings: [] };
68266826- }
68276827-68286828- let raw: Record<string, unknown>;
68296829- try {
68306830- const manifestJson = fs.readFileSync(manifestPath, 'utf-8');
68316831- raw = JSON.parse(manifestJson);
68326832- } catch (parseErr) {
68336833- return { valid: false, errors: [`Invalid JSON in manifest.json: ${parseErr}`], warnings: [] };
68346834- }
68356835-68366836- // Check manifest version
68376837- const version = detectManifestVersion(raw);
68386838- if (version !== 'v2') {
68396839- errors.push(`Manifest is ${version}, expected v2`);
68406840- }
68416841-68426842- // Run schema validation
68436843- const validationErrors = validateTileManifest(raw);
68446844- for (const ve of validationErrors) {
68456845- errors.push(`${ve.path}: ${ve.message}`);
68466846- }
68476847-68486848- // Check that all files referenced in tiles[].url exist on disk
68496849- const tiles = raw.tiles as Array<{ id: string; url: string }> | undefined;
68506850- if (tiles && Array.isArray(tiles)) {
68516851- for (const tile of tiles) {
68526852- if (tile.url) {
68536853- const tilePath = path.join(featurePath, tile.url);
68546854- if (!fs.existsSync(tilePath)) {
68556855- errors.push(`Tile "${tile.id}" references "${tile.url}" which does not exist`);
68566856- }
68576857- }
68586858- }
68596859- }
68606860-68616861- // Check settingsSchema file if declared
68626862- const settingsSchema = raw.settingsSchema as string | undefined;
68636863- if (settingsSchema) {
68646864- const schemaPath = path.join(featurePath, settingsSchema);
68656865- if (!fs.existsSync(schemaPath)) {
68666866- errors.push(`Settings schema file "${settingsSchema}" not found`);
68676867- }
68686868- }
68696869-68706870- // Warnings for common issues
68716871- if (!raw.description || (raw.description as string).trim() === '') {
68726872- warnings.push('No description set in manifest');
68736873- }
68746874- if (!raw.version) {
68756875- warnings.push('No version set in manifest');
68766876- }
68776877-68786878- return {
68796879- valid: errors.length === 0,
68806880- errors,
68816881- warnings,
68826882- };
68836883- } catch (err) {
68846884- const message = err instanceof Error ? err.message : String(err);
68856885- return { valid: false, errors: [`Validation failed: ${message}`], warnings: [] };
68866886- }
68876887- });
57095709+ // NOTE: bare `feature-*` ipcMain handlers (feature-browse, feature-publish,
57105710+ // feature-update, feature-dev, feature-devtools) were deleted in the
57115711+ // v1-removal pass. Every caller routes through the strict
57125712+ // `tile:features:*` mirrors above, which enforce token + capability gating.
57135713+ // See `tile-preload.cts` `api.features` for the renderer surface.
6888571468895715 // ── tile:extensions:* strict shims ────────────────────────────────
68905716 //
+1
docs/tasks.md
···36363737## Bugs
38383939+- [ ] **Migrate bootstrap IPC channels to tile:lifecycle:* namespace.** `session-restore-pending` and `frontend-ready` in `backend/electron/entry.ts` are bare `ipcMain.handle()` registrations from before the tile system initializes. They're only reachable from trustedBuiltin renderers via `api.invoke()`, so the security gap is theoretical, but they're the last bare main-process IPC handlers outside `tile-ipc-gate.ts`. Decision skipped 2026-04-24: leaving them bare for now since they exist *before* any tile loads — the indirection through tile-ipc-gate would require either bootstrapping the gate earlier or having two IPC modes (bootstrap vs. post-init). Worth revisiting if tile-ipc-gate ever gains a "no token required for these specific channels" mode, or if entry.ts gets folded into a tile lifecycle.
3940- [ ] **Page host window jumps to wrong position after switching primary monitor.** Repro: connect external monitor, set it primary; open page host (cmd+L); disconnect/swap so the laptop becomes primary; cmd+L again — the whole window shifts to a different (off-screen / wrong-display) position. Likely cause: `computeWindowBounds`/canvas positioning caches screen bounds at first compute or reads stale `screen.getPrimaryDisplay()`/`getDisplayNearestPoint` results across display topology changes. Fix path: subscribe to Electron's `screen` `display-added`/`display-removed`/`display-metrics-changed` events and recompute the page-host window's normal+maximized bounds against current displays before show; clamp restored positions to a visible display. Surfaced 2026-04-24.
4041- [x] **post.nl → postnl.nl redirect shows blank page.** Fixed 2026-04-17 commit `5dfa3e7a` — `did-redirect-navigation` listener in `app/page/page.js` updates `latestNavigationUrl` so the did-fail-load(-3) handler doesn't abort the redirect target mid-load.
4142- [ ] **v2 tile `cmd:execute:<name>:result` doesn't reach v1 extension-host subscribers.** (Will be obsolete after v1 removal — see [v1-removal-plan.md](v1-removal-plan.md). Documented here in case a quick patch is desired before that lands.) Tests in `smoke.spec.ts` (Command Execution describe — tag/untag/tagset/widget-update tests, ~7 of them) publish `cmd:execute:tag` from bgWindow with `expectResult:true, resultTopic:'cmd:execute:tag:result'`, then subscribe to that result topic. The tags v2 tile receives the publish, runs `executeTag()`, and tile-preload sends `tile:pubsub:publish` with the result topic — BUT the bgWindow subscriber never fires. Confirmed: 2026-04-17 added `cmd:execute:*` to tags' pubsub allowlist (in `b3a80b27`) and `waitForCommand` in beforeAll (in `daa4221a`); both necessary but tests still time out at 10s. Per Opus agent triage: bgWindow is `peek://app/background.html`, NOT in `extensionHostWindow`/`extensionWindows`/`getAllTileWindows()` — `extensionBroadcaster` skips it. bgWindow subscribes via legacy `IPC_CHANNELS.SUBSCRIBE` (`ipc.ts:4466`); the in-proc callback should fire on `publish()` but doesn't. That's the next concrete investigation.