fix: proper opening of the mac app on bookmarklet

This commit is contained in:
2026-02-22 18:38:56 +01:00
parent 2d451dc1f0
commit c6afd545a6
10 changed files with 401 additions and 22 deletions

View File

@@ -19,6 +19,8 @@ let activePreviewPostId: string | null = null;
let appInitialized = false; let appInitialized = false;
let blogmarkQueue: string[] = []; let blogmarkQueue: string[] = [];
let blogmarkQueueProcessing = false; let blogmarkQueueProcessing = false;
let pendingBlogmarkCreatedEvents: unknown[] = [];
let rendererReady = false;
const PREVIEW_SERVER_PORT = 4123; const PREVIEW_SERVER_PORT = 4123;
const BLOG_PREVIEW_POST_MENU_ID = APP_MENU_ITEM_IDS.previewPost; const BLOG_PREVIEW_POST_MENU_ID = APP_MENU_ITEM_IDS.previewPost;
const BLOGMARK_PROTOCOL = 'bds'; const BLOGMARK_PROTOCOL = 'bds';
@@ -201,6 +203,7 @@ protocol.registerSchemesAsPrivileged([
]); ]);
function createWindow(): void { function createWindow(): void {
rendererReady = false;
const isMac = process.platform === 'darwin'; const isMac = process.platform === 'darwin';
const initialWindowState = resolveInitialWindowState(); const initialWindowState = resolveInitialWindowState();
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
@@ -376,8 +379,22 @@ async function processBlogmarkDeepLink(rawDeepLink: string): Promise<void> {
categories: preferredCategory ? [preferredCategory] : [], categories: preferredCategory ? [preferredCategory] : [],
}); });
if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow && !mainWindow.isDestroyed() && rendererReady) {
mainWindow.webContents.send('blogmark:created', createdPost); mainWindow.webContents.send('blogmark:created', createdPost);
} else {
pendingBlogmarkCreatedEvents.push(createdPost);
}
}
function flushPendingBlogmarkCreatedEvents(): void {
if (!rendererReady || !mainWindow || mainWindow.isDestroyed() || pendingBlogmarkCreatedEvents.length === 0) {
return;
}
const queuedEvents = pendingBlogmarkCreatedEvents;
pendingBlogmarkCreatedEvents = [];
for (const payload of queuedEvents) {
mainWindow.webContents.send('blogmark:created', payload);
} }
} }
@@ -709,6 +726,12 @@ async function initialize(): Promise<void> {
setPreviewPostMenuEnabled(Boolean(activePreviewPostId)); setPreviewPostMenuEnabled(Boolean(activePreviewPostId));
}); });
ipcMain.handle('app:rendererReady', async () => {
rendererReady = true;
flushPendingBlogmarkCreatedEvents();
return true;
});
// Initialize and register chat handlers // Initialize and register chat handlers
initializeChatHandlers(() => mainWindow); initializeChatHandlers(() => mainWindow);
registerChatHandlers(); registerChatHandlers();

View File

@@ -147,6 +147,7 @@ export const electronAPI: ElectronAPI = {
readProjectMetadata: (folderPath: string) => ipcRenderer.invoke('app:readProjectMetadata', folderPath), readProjectMetadata: (folderPath: string) => ipcRenderer.invoke('app:readProjectMetadata', folderPath),
getBlogmarkBookmarklet: () => ipcRenderer.invoke('app:getBlogmarkBookmarklet'), getBlogmarkBookmarklet: () => ipcRenderer.invoke('app:getBlogmarkBookmarklet'),
copyToClipboard: (text: string) => ipcRenderer.invoke('app:copyToClipboard', text), copyToClipboard: (text: string) => ipcRenderer.invoke('app:copyToClipboard', text),
notifyRendererReady: () => ipcRenderer.invoke('app:rendererReady'),
setPreviewPostTarget: (postId: string | null) => ipcRenderer.invoke('app:setPreviewPostTarget', postId), setPreviewPostTarget: (postId: string | null) => ipcRenderer.invoke('app:setPreviewPostTarget', postId),
triggerMenuAction: (action: string) => ipcRenderer.invoke('app:triggerMenuAction', action), triggerMenuAction: (action: string) => ipcRenderer.invoke('app:triggerMenuAction', action),
}, },

View File

@@ -567,6 +567,7 @@ export interface ElectronAPI {
readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; publicUrl?: string; mainLanguage?: string } | null>; readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; publicUrl?: string; mainLanguage?: string } | null>;
getBlogmarkBookmarklet: () => Promise<string>; getBlogmarkBookmarklet: () => Promise<string>;
copyToClipboard: (text: string) => Promise<boolean>; copyToClipboard: (text: string) => Promise<boolean>;
notifyRendererReady: () => Promise<boolean>;
setPreviewPostTarget: (postId: string | null) => Promise<void>; setPreviewPostTarget: (postId: string | null) => Promise<void>;
triggerMenuAction: (action: string) => Promise<void>; triggerMenuAction: (action: string) => Promise<void>;
}; };

View File

@@ -1,10 +1,12 @@
import React, { useEffect } from 'react'; import React, { useEffect, useRef } from 'react';
import { ActivityBar, Sidebar, Editor, StatusBar, Panel, TabBar, ToastContainer, showToast, ResizablePanel, WindowTitleBar } from './components'; import { ActivityBar, Sidebar, Editor, StatusBar, Panel, TabBar, ToastContainer, showToast, ResizablePanel, WindowTitleBar } from './components';
import { useAppStore, PostData, MediaData, TaskProgress } from './store'; import { useAppStore, PostData, MediaData, TaskProgress } from './store';
import { loadTabsForProject, saveTabsForProject } from './utils'; import { loadTabsForProject, saveTabsForProject } from './utils';
import { openEntityTab, openSingletonToolTab } from './navigation/tabPolicy'; import { openSingletonToolTab } from './navigation/tabPolicy';
import { persistSiteValidationReport } from './navigation/siteValidationPersistence'; import { persistSiteValidationReport } from './navigation/siteValidationPersistence';
import { executeActivityClick } from './navigation/activityExecution'; import { executeActivityClick } from './navigation/activityExecution';
import { handleBlogmarkCreatedEvent } from './navigation/blogmarkHandling';
import { createDeferredEventGate } from './navigation/deferredEventGate';
import { createAndFocusPost } from './navigation/postCreation'; import { createAndFocusPost } from './navigation/postCreation';
import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from './utils/picoTheme'; import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from './utils/picoTheme';
import { useI18n } from './i18n'; import { useI18n } from './i18n';
@@ -33,6 +35,25 @@ const App: React.FC = () => {
openTab, openTab,
restoreTabState, restoreTabState,
} = useAppStore(); } = useAppStore();
const blogmarkEventGateRef = useRef(createDeferredEventGate<PostData>());
const processBlogmarkCreated = (created: PostData) => {
addPost(created);
const state = useAppStore.getState();
handleBlogmarkCreatedEvent(
{
activeView: state.activeView,
sidebarVisible: state.sidebarVisible,
},
created,
{
setActiveView: state.setActiveView,
toggleSidebar: state.toggleSidebar,
setSelectedPost: state.setSelectedPost,
openTab: state.openTab,
},
);
};
// Load initial data // Load initial data
useEffect(() => { useEffect(() => {
@@ -80,6 +101,9 @@ const App: React.FC = () => {
console.error('Failed to load initial data:', error); console.error('Failed to load initial data:', error);
} finally { } finally {
setLoading(false); setLoading(false);
setTimeout(() => {
blogmarkEventGateRef.current.markReady(processBlogmarkCreated);
}, 0);
} }
}; };
@@ -216,28 +240,12 @@ const App: React.FC = () => {
unsubscribers.push( unsubscribers.push(
window.electronAPI?.on('blogmark:created', (post: unknown) => { window.electronAPI?.on('blogmark:created', (post: unknown) => {
const created = post as { id?: string } | null; const created = post as PostData;
if (!created?.id) { if (!created?.id) {
return; return;
} }
const state = useAppStore.getState(); blogmarkEventGateRef.current.push(created, processBlogmarkCreated);
executeActivityClick(
{
activeView: state.activeView,
sidebarVisible: state.sidebarVisible,
tabs: state.tabs,
activeTabId: state.activeTabId,
},
'posts',
{
setActiveView: state.setActiveView,
toggleSidebar: state.toggleSidebar,
},
);
state.setSelectedPost(created.id);
openEntityTab(state.openTab, 'post', created.id, 'preview');
}) || (() => {}) }) || (() => {})
); );
@@ -475,6 +483,10 @@ const App: React.FC = () => {
}) || (() => {}) }) || (() => {})
); );
void window.electronAPI?.app.notifyRendererReady?.().catch((error) => {
console.error('Failed to notify renderer readiness:', error);
});
return () => { return () => {
unsubscribers.forEach(unsub => unsub()); unsubscribers.forEach(unsub => unsub());
}; };

View File

@@ -0,0 +1,40 @@
import { openEntityTab } from './tabPolicy';
import type { SidebarView } from './sidebarViewRegistry';
interface BlogmarkStateSnapshot {
activeView: SidebarView;
sidebarVisible: boolean;
}
interface BlogmarkCreatedPayload {
id?: string;
}
interface BlogmarkHandlers {
setActiveView: (view: SidebarView) => void;
toggleSidebar: () => void;
setSelectedPost: (id: string) => void;
openTab: (tab: { type: 'post'; id: string; isTransient: boolean }) => void;
}
export function handleBlogmarkCreatedEvent(
snapshot: BlogmarkStateSnapshot,
payload: BlogmarkCreatedPayload | null | undefined,
handlers: BlogmarkHandlers,
): void {
const postId = typeof payload?.id === 'string' ? payload.id : '';
if (!postId) {
return;
}
if (snapshot.activeView !== 'posts') {
handlers.setActiveView('posts');
}
if (!snapshot.sidebarVisible) {
handlers.toggleSidebar();
}
handlers.setSelectedPost(postId);
openEntityTab(handlers.openTab, 'post', postId, 'preview');
}

View File

@@ -0,0 +1,35 @@
export interface DeferredEventGate<T> {
push: (event: T, consume: (event: T) => void) => void;
markReady: (consume: (event: T) => void) => void;
isReady: () => boolean;
}
export function createDeferredEventGate<T>(): DeferredEventGate<T> {
let ready = false;
let queue: T[] = [];
return {
push: (event, consume) => {
if (ready) {
consume(event);
return;
}
queue.push(event);
},
markReady: (consume) => {
if (ready) {
return;
}
ready = true;
const queuedEvents = queue;
queue = [];
for (const event of queuedEvents) {
consume(event);
}
},
isReady: () => ready,
};
}

View File

@@ -650,6 +650,7 @@ describe('main bootstrap preview behavior', () => {
it('handles bds deep-link by creating a blogmark post with preferred category', async () => { it('handles bds deep-link by creating a blogmark post with preferred category', async () => {
const listeners = new Map<string, (...args: any[]) => void>(); const listeners = new Map<string, (...args: any[]) => void>();
const ipcHandlers = new Map<string, (...args: any[]) => any>();
const mockApp = { const mockApp = {
name: 'bDS', name: 'bDS',
whenReady: vi.fn(() => Promise.resolve()), whenReady: vi.fn(() => Promise.resolve()),
@@ -691,7 +692,9 @@ describe('main bootstrap preview behavior', () => {
}, },
ipcMain: { ipcMain: {
on: vi.fn(), on: vi.fn(),
handle: vi.fn(), handle: vi.fn((channel: string, handler: (...args: any[]) => any) => {
ipcHandlers.set(channel, handler);
}),
removeHandler: vi.fn(), removeHandler: vi.fn(),
}, },
protocol: { protocol: {
@@ -794,9 +797,181 @@ describe('main bootstrap preview behavior', () => {
}), }),
); );
const rendererReadyHandler = ipcHandlers.get('app:rendererReady');
await rendererReadyHandler?.();
expect(windows[0]?.webContents.send).toHaveBeenCalledWith( expect(windows[0]?.webContents.send).toHaveBeenCalledWith(
'blogmark:created', 'blogmark:created',
expect.objectContaining({ id: 'new-post-id' }), expect.objectContaining({ id: 'new-post-id' }),
); );
}); });
it('queues blogmark created event until renderer has finished loading', async () => {
const listeners = new Map<string, (...args: any[]) => void>();
const webContentsListeners = new Map<string, (...args: any[]) => void>();
const ipcHandlers = new Map<string, (...args: any[]) => any>();
const mockApp = {
name: 'bDS',
whenReady: vi.fn(() => Promise.resolve()),
on: vi.fn((event: string, callback: (...args: any[]) => void) => {
listeners.set(event, callback);
}),
quit: vi.fn(),
requestSingleInstanceLock: vi.fn(() => true),
setAsDefaultProtocolClient: vi.fn(() => true),
};
const windows: Array<{ webContents: { send: ReturnType<typeof vi.fn> } }> = [];
let rendererLoading = true;
class MockBrowserWindow {
static getAllWindows = vi.fn(() => windows as any);
loadURL = vi.fn();
loadFile = vi.fn();
on = vi.fn();
isDestroyed = vi.fn(() => false);
webContents = {
on: vi.fn((event: string, callback: (...args: any[]) => void) => {
webContentsListeners.set(event, callback);
}),
isLoadingMainFrame: vi.fn(() => rendererLoading),
send: vi.fn(),
openDevTools: vi.fn(),
toggleDevTools: vi.fn(),
};
constructor() {
windows.push(this as any);
}
}
vi.doMock('electron', () => ({
app: mockApp,
BrowserWindow: MockBrowserWindow,
Menu: {
buildFromTemplate: vi.fn(() => ({})),
setApplicationMenu: vi.fn(),
},
ipcMain: {
on: vi.fn(),
handle: vi.fn((channel: string, handler: (...args: any[]) => any) => {
ipcHandlers.set(channel, handler);
}),
removeHandler: vi.fn(),
},
protocol: {
registerSchemesAsPrivileged: vi.fn(),
handle: vi.fn(),
},
net: {
fetch: vi.fn(),
},
shell: {
openExternal: vi.fn(),
openPath: vi.fn(),
},
}));
class MockPreviewServer {
start = vi.fn().mockResolvedValue(4123);
stop = vi.fn().mockResolvedValue(undefined);
getBaseUrl = vi.fn(() => 'http://127.0.0.1:4123');
}
const createPost = vi.fn().mockResolvedValue({
id: 'queued-post-id',
title: 'Queued title',
content: '[Queued title](<https://example.com/>)',
categories: ['article'],
});
vi.doMock('../../src/main/engine/PreviewServer', () => ({
PreviewServer: MockPreviewServer,
}));
vi.doMock('../../src/main/engine/PostEngine', () => ({
getPostEngine: vi.fn(() => ({
getPost: vi.fn().mockResolvedValue(null),
createPost,
})),
}));
vi.doMock('../../src/main/engine/MetaEngine', () => ({
getMetaEngine: vi.fn(() => ({
getProjectMetadata: vi.fn().mockResolvedValue({ blogmarkCategory: 'article' }),
})),
}));
vi.doMock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
initializeLocal: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getLocal: vi.fn(() => ({
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
get: vi.fn().mockResolvedValue(null),
})),
})),
})),
})),
getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })),
})),
}));
vi.doMock('../../src/main/ipc', () => ({
registerIpcHandlers: vi.fn(),
registerChatHandlers: vi.fn(),
initializeChatHandlers: vi.fn(),
cleanupChatHandlers: vi.fn().mockResolvedValue(undefined),
}));
vi.doMock('../../src/main/database/schema', () => ({
media: {},
}));
vi.doMock('drizzle-orm', () => ({
eq: vi.fn(),
}));
vi.doMock('../../src/main/engine/MediaEngine', () => ({
getMediaEngine: vi.fn(() => ({
getThumbnailPaths: vi.fn().mockResolvedValue({ small: null }),
})),
}));
await import('../../src/main/main');
await new Promise((resolve) => setTimeout(resolve, 0));
const openUrl = listeners.get('open-url');
expect(openUrl).toBeTruthy();
const preventDefault = vi.fn();
openUrl?.({ preventDefault } as any, 'bds://new-post?title=Queued%20title&url=https%3A%2F%2Fexample.com%2F');
await new Promise((resolve) => setTimeout(resolve, 0));
expect(createPost).toHaveBeenCalledTimes(1);
expect(windows[0]?.webContents.send).not.toHaveBeenCalledWith(
'blogmark:created',
expect.anything(),
);
rendererLoading = false;
const didFinishLoad = webContentsListeners.get('did-finish-load');
didFinishLoad?.();
expect(windows[0]?.webContents.send).not.toHaveBeenCalledWith(
'blogmark:created',
expect.anything(),
);
const rendererReadyHandler = ipcHandlers.get('app:rendererReady');
await rendererReadyHandler?.();
expect(windows[0]?.webContents.send).toHaveBeenCalledWith(
'blogmark:created',
expect.objectContaining({ id: 'queued-post-id' }),
);
});
}); });

View File

@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from 'vitest';
import { handleBlogmarkCreatedEvent } from '../../../src/renderer/navigation/blogmarkHandling';
describe('handleBlogmarkCreatedEvent', () => {
it('does not collapse sidebar when already in posts view', () => {
const setActiveView = vi.fn();
const toggleSidebar = vi.fn();
const setSelectedPost = vi.fn();
const openTab = vi.fn();
handleBlogmarkCreatedEvent(
{
activeView: 'posts',
sidebarVisible: true,
},
{
id: 'post-1',
},
{
setActiveView,
toggleSidebar,
setSelectedPost,
openTab,
},
);
expect(setActiveView).not.toHaveBeenCalled();
expect(toggleSidebar).not.toHaveBeenCalled();
expect(setSelectedPost).toHaveBeenCalledWith('post-1');
expect(openTab).toHaveBeenCalledTimes(1);
});
it('switches to posts view and opens sidebar when needed', () => {
const setActiveView = vi.fn();
const toggleSidebar = vi.fn();
const setSelectedPost = vi.fn();
const openTab = vi.fn();
handleBlogmarkCreatedEvent(
{
activeView: 'media',
sidebarVisible: false,
},
{
id: 'post-2',
},
{
setActiveView,
toggleSidebar,
setSelectedPost,
openTab,
},
);
expect(setActiveView).toHaveBeenCalledWith('posts');
expect(toggleSidebar).toHaveBeenCalledTimes(1);
expect(setSelectedPost).toHaveBeenCalledWith('post-2');
expect(openTab).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,31 @@
import { describe, expect, it, vi } from 'vitest';
import { createDeferredEventGate } from '../../../src/renderer/navigation/deferredEventGate';
describe('createDeferredEventGate', () => {
it('queues events until marked ready', () => {
const gate = createDeferredEventGate<string>();
const consume = vi.fn();
gate.push('first', consume);
gate.push('second', consume);
expect(consume).not.toHaveBeenCalled();
gate.markReady(consume);
expect(consume).toHaveBeenCalledTimes(2);
expect(consume).toHaveBeenNthCalledWith(1, 'first');
expect(consume).toHaveBeenNthCalledWith(2, 'second');
});
it('consumes immediately after ready', () => {
const gate = createDeferredEventGate<string>();
const consume = vi.fn();
gate.markReady(consume);
gate.push('now', consume);
expect(consume).toHaveBeenCalledTimes(1);
expect(consume).toHaveBeenCalledWith('now');
});
});

View File

@@ -130,6 +130,7 @@ Object.defineProperty(globalThis, 'window', {
triggerMenuAction: vi.fn(), triggerMenuAction: vi.fn(),
getBlogmarkBookmarklet: vi.fn(), getBlogmarkBookmarklet: vi.fn(),
copyToClipboard: vi.fn(), copyToClipboard: vi.fn(),
notifyRendererReady: vi.fn(),
}, },
import: { import: {
selectAndAnalyze: vi.fn(), selectAndAnalyze: vi.fn(),