Skip to content

Commit 2019607

Browse files
authored
Merge pull request #4535 from tloncorp/po/electron-notifications
desktop: add notifications (and icons)
2 parents e14f8a0 + 4968163 commit 2019607

19 files changed

+234
-17
lines changed

apps/tlon-desktop/package.json

+3
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"gatekeeperAssess": false,
6767
"entitlements": null,
6868
"entitlementsInherit": null,
69+
"icon": "resources/icons/mac/icon.icns",
6970
"target": [
7071
{
7172
"target": "dmg",
@@ -76,6 +77,7 @@
7677
]
7778
},
7879
"win": {
80+
"icon": "resources/icons/win/icon.ico",
7981
"target": [
8082
{
8183
"target": "nsis",
@@ -86,6 +88,7 @@
8688
]
8789
},
8890
"linux": {
91+
"icon": "resources/icons/png",
8992
"target": "AppImage"
9093
}
9194
}
56.6 KB
Binary file not shown.
Loading
Loading
341 Bytes
Loading
418 Bytes
Loading
Loading
508 Bytes
Loading
759 Bytes
Loading
Loading
942 Bytes
Loading
353 KB
Binary file not shown.

apps/tlon-desktop/src/main/index.ts

+14-16
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { BrowserWindow, app, ipcMain, shell } from 'electron';
44
import fs from 'fs';
55
import path from 'path';
66

7+
import { setupNotificationService } from './notification-service';
78
import { setupSQLiteIPC } from './sqlite-service';
89
import store from './store';
910

@@ -93,18 +94,23 @@ interface AuthInfo {
9394
let mainWindow: BrowserWindow | null = null;
9495

9596
async function createWindow() {
96-
// Create the browser window.
9797
mainWindow = new BrowserWindow({
9898
width: 1200,
9999
height: 800,
100100
webPreferences: {
101101
nodeIntegration: false,
102102
contextIsolation: true,
103103
preload: path.resolve(__dirname, '../../build/main/preload.js'),
104+
// SECURITY NOTE: webSecurity is disabled to allow communication with local Urbit ships
105+
// This is necessary because Urbit doesn't properly handle CORS/OPTIONS preflight requests
106+
// for SSE connections (it just prints `eyre: session not a put` and doesn't respond).
107+
// Long-term, we should implement a more secure solution.
104108
webSecurity: false,
105109
},
106110
});
107111

112+
setupNotificationService(mainWindow);
113+
108114
const webSession = mainWindow.webContents.session;
109115

110116
// Add auth cookie to requests
@@ -129,26 +135,17 @@ async function createWindow() {
129135
});
130136

131137
// Configure session for CORS handling
132-
// We've disabled web security for now, so we don't need to worry about this.
133-
// We should probably revisit this and re-enable web security.
134-
// webSession.webRequest.onBeforeSendHeaders((details, callback) => {
135-
// // Only modify headers for requests to the configured ship
136-
// if (cachedShipUrl && details.url.startsWith(cachedShipUrl)) {
137-
// callback({
138-
// requestHeaders: details.requestHeaders,
139-
// });
140-
// } else {
141-
// callback({ requestHeaders: details.requestHeaders });
142-
// }
143-
// });
144-
138+
// Disabled for now, see above note about disabling webSecurity
145139
// webSession.webRequest.onHeadersReceived((details, callback) => {
146140
// // Only modify headers for responses from the configured ship
147141
// if (cachedShipUrl && details.url.startsWith(cachedShipUrl)) {
148142
// console.log('Setting CORS headers for response from', cachedShipUrl);
149143

150144
// if (details.method === 'OPTIONS') {
151-
// console.log('Setting CORS headers for OPTIONS request');
145+
// console.log(
146+
// 'Setting CORS headers for OPTIONS request',
147+
// JSON.stringify(details)
148+
// );
152149
// callback({
153150
// responseHeaders: {
154151
// ...details.responseHeaders,
@@ -162,13 +159,14 @@ async function createWindow() {
162159
// status: ['200'],
163160
// statusText: ['OK'],
164161
// },
162+
// statusLine: 'HTTP/1.1 200 OK',
165163
// });
166164
// } else {
167165
// console.log('Setting CORS headers for non-OPTIONS request');
168166
// callback({
169167
// responseHeaders: {
170168
// ...details.responseHeaders,
171-
// 'Access-Control-Allow-Origin': ['http://localhost:3000'],
169+
// 'Access-Control-Allow-Origin': ['*'],
172170
// 'Access-Control-Allow-Methods': ['GET, POST, PUT, DELETE, OPTIONS'],
173171
// 'Access-Control-Allow-Headers': [
174172
// 'Content-Type, Authorization, Cookie',
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { BrowserWindow, Notification, ipcMain } from 'electron';
2+
3+
export function setupNotificationService(mainWindow: BrowserWindow) {
4+
ipcMain.handle('show-notification', (event, { title, body, data }) => {
5+
// Don't show notifications if window is focused and visible
6+
if (mainWindow.isFocused() && mainWindow.isVisible()) {
7+
return false;
8+
}
9+
10+
const notification = new Notification({
11+
title,
12+
body,
13+
silent: false
14+
});
15+
16+
notification.on('click', () => {
17+
// Focus window when notification is clicked
18+
if (mainWindow.isMinimized()) mainWindow.restore();
19+
mainWindow.focus();
20+
21+
// Send notification data back to renderer for navigation
22+
mainWindow.webContents.send('notification-clicked', data);
23+
});
24+
25+
notification.show();
26+
return true;
27+
});
28+
}

apps/tlon-desktop/src/preload/preload.ts

+10
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
1111
storeAuthInfo: (authInfo) => ipcRenderer.invoke('store-auth-info', authInfo),
1212
getAuthInfo: () => ipcRenderer.invoke('get-auth-info'),
1313
clearAuthInfo: () => ipcRenderer.invoke('clear-auth-info'),
14+
15+
// Notification functions
16+
showNotification: (options) => ipcRenderer.invoke('show-notification', options),
17+
onNotificationClicked: (callback) => {
18+
const handler = (_event: IpcRendererEvent, data: any) => callback(data);
19+
ipcRenderer.on('notification-clicked', handler);
20+
return () => {
21+
ipcRenderer.removeListener('notification-clicked', handler);
22+
};
23+
},
1424
});
1525

1626
// Expose SQLite bridge

apps/tlon-web-new/src/app.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { useIsDark, useIsMobile } from '@/logic/useMedia';
3636
import { preSig } from '@/logic/utils';
3737
import { toggleDevTools, useLocalState, useShowDevTools } from '@/state/local';
3838
import { useAnalyticsId, useLogActivity, useTheme } from '@/state/settings';
39+
import useDesktopNotifications from '@tloncorp/app/hooks/useDesktopNotifications';
3940

4041
import { DesktopLoginScreen } from './components/DesktopLoginScreen';
4142
import { isElectron } from './electron-bridge';
@@ -268,7 +269,8 @@ function ConnectedDesktopApp({
268269
const configureClient = useConfigureUrbitClient();
269270
const hasSyncedRef = React.useRef(false);
270271
useFindSuggestedContacts();
271-
272+
useDesktopNotifications(clientReady);
273+
272274
useEffect(() => {
273275
window.ship = ship;
274276
window.our = ship;

apps/tlon-web-new/src/electron-bridge.ts

+4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ declare global {
77
storeAuthInfo: (authInfo: any) => Promise<boolean>;
88
getAuthInfo: () => Promise<any>;
99
clearAuthInfo: () => Promise<boolean>;
10+
11+
// Notification functions
12+
showNotification: (options: { title: string; body: string; data?: any }) => Promise<boolean>;
13+
onNotificationClicked: (callback: (data: any) => void) => () => void;
1014
};
1115
sqliteBridge?: {
1216
init: () => Promise<boolean>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { createDevLogger } from '@tloncorp/shared';
2+
import * as api from '@tloncorp/shared/api';
3+
import * as db from '@tloncorp/shared/db';
4+
import { getTextContent } from '@tloncorp/shared/urbit';
5+
import { useCallback, useEffect, useRef } from 'react';
6+
7+
import { useIsElectron } from './useIsElectron';
8+
9+
const logger = createDevLogger('useDesktopNotifications', false);
10+
11+
interface NotificationData {
12+
type: string;
13+
channelId?: string;
14+
groupId?: string;
15+
}
16+
17+
export default function useDesktopNotifications(isClientReady: boolean) {
18+
const processedNotifications = useRef<Set<string>>(new Set());
19+
const isElectron = useIsElectron();
20+
21+
const processActivityEvent = useCallback(
22+
async (activityEvent: db.ActivityEvent) => {
23+
if (!isElectron || !window.electronAPI) {
24+
return;
25+
}
26+
27+
const notificationKey = `${activityEvent.channelId}-${activityEvent.timestamp}`;
28+
29+
if (processedNotifications.current.has(notificationKey)) {
30+
return;
31+
}
32+
33+
processedNotifications.current.add(notificationKey);
34+
35+
// Limit the size of the set to avoid memory leaks
36+
if (processedNotifications.current.size > 100) {
37+
// Keep only the most recent 50 notification keys
38+
processedNotifications.current = new Set(
39+
Array.from(processedNotifications.current).slice(-50)
40+
);
41+
}
42+
43+
let body = '';
44+
45+
if (!activityEvent.channelId) {
46+
logger.error('No channel ID in activity event:', activityEvent);
47+
return;
48+
}
49+
50+
try {
51+
const channel = await db.getChannelWithRelations({
52+
id: activityEvent.channelId,
53+
});
54+
if (activityEvent.content) {
55+
body =
56+
getTextContent(activityEvent.content as api.PostContent) ||
57+
'New message';
58+
} else {
59+
body = 'New message';
60+
}
61+
62+
const contactId = activityEvent.authorId;
63+
const contact = contactId
64+
? await db.getContact({ id: contactId })
65+
: null;
66+
67+
if (!channel) return;
68+
69+
let title = channel.title
70+
? channel.title
71+
: contact?.nickname
72+
? contact.nickname
73+
: contactId || 'New message';
74+
75+
const contactName = contact?.peerNickname
76+
? contact.peerNickname
77+
: contact?.customNickname
78+
? contact.customNickname
79+
: contactId;
80+
81+
if (activityEvent.groupId) {
82+
const group = await db.getGroup({ id: activityEvent.groupId });
83+
if (group) {
84+
if (activityEvent.content) {
85+
body = `${contactName}: ${getTextContent(activityEvent.content as api.PostContent)}`;
86+
title = title + ` in ${group.title}`;
87+
} else {
88+
body = `New message in ${group.title}`;
89+
}
90+
}
91+
}
92+
93+
logger.log('Showing desktop notification:', title);
94+
95+
window.electronAPI.showNotification({
96+
title,
97+
body,
98+
data: {
99+
type: 'channel',
100+
channelId: activityEvent.channelId,
101+
groupId: channel.groupId,
102+
},
103+
});
104+
} catch (error) {
105+
logger.error(
106+
'Error processing channel activity for notifications',
107+
error
108+
);
109+
}
110+
},
111+
[]
112+
);
113+
114+
const handleNotificationClick = useCallback(
115+
async (data: NotificationData) => {
116+
if (!data || !data.channelId) return;
117+
118+
try {
119+
logger.log('Notification clicked:', data);
120+
await api.markChatRead(data.channelId);
121+
122+
// In the future, we could add navigation logic here
123+
} catch (error) {
124+
logger.error('Error handling notification click', error);
125+
}
126+
},
127+
[]
128+
);
129+
130+
useEffect(() => {
131+
if (!isElectron || !window.electronAPI || !isClientReady) return;
132+
133+
const handleActivityEvent = (event: api.ActivityEvent) => {
134+
logger.log('Activity event:', event);
135+
if (event.type === 'addActivityEvent' && event.events[0].shouldNotify) {
136+
processActivityEvent(event.events[0]);
137+
}
138+
};
139+
140+
api.subscribeToActivity(handleActivityEvent);
141+
142+
const unsubscribeNotificationClick =
143+
window.electronAPI.onNotificationClicked(handleNotificationClick);
144+
145+
return () => {
146+
unsubscribeNotificationClick();
147+
};
148+
}, [
149+
processActivityEvent,
150+
handleNotificationClick,
151+
isClientReady,
152+
isElectron,
153+
]);
154+
}

packages/app/window.ts

+18
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,24 @@
1+
interface ElectronAPI {
2+
setUrbitShip: (shipUrl: string) => Promise<boolean>;
3+
getVersion: () => Promise<string>;
4+
loginToShip: (shipUrl: string, accessCode: string) => Promise<string>;
5+
storeAuthInfo: (authInfo: any) => Promise<boolean>;
6+
getAuthInfo: () => Promise<any>;
7+
clearAuthInfo: () => Promise<boolean>;
8+
9+
// Notification functions
10+
showNotification: (options: {
11+
title: string;
12+
body: string;
13+
data?: any;
14+
}) => Promise<boolean>;
15+
onNotificationClicked: (callback: (data: any) => void) => () => void;
16+
}
17+
118
declare global {
219
interface Window {
320
our: string;
21+
electronAPI?: ElectronAPI;
422
}
523
}
624

0 commit comments

Comments
 (0)