diff --git a/.gitignore b/.gitignore index 45095bd..d8a89e0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ build/ .cache/ compile_commands.json CMakeLists.txt.user +tags diff --git a/go/Taskfile.yml b/go/Taskfile.yml index 08fc5e6..0c37f3a 100644 --- a/go/Taskfile.yml +++ b/go/Taskfile.yml @@ -11,6 +11,11 @@ tasks: - task: genproto - go generate ./... + format: + desc: Format go files + cmds: + - go fmt ./... + genproto: desc: Generate Go code from .proto files via docker vars: diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go index 11395bc..09ec9fd 100644 --- a/go/cmd/orion/main.go +++ b/go/cmd/orion/main.go @@ -103,7 +103,7 @@ func main() { } defer firestoreClient.Close() - networkSvc := network.NewService(db.Pool(), aeroSvc, billingSvc, livestore.NewMembershipPublisher(firestoreClient)) + networkSvc := network.NewService(db.Pool(), aeroSvc, billingSvc, livestore.NewMembershipPublisher(firestoreClient), humanSvc) particleSvc := particle.NewService(db.Pool(), networkSvc) depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{ GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"), diff --git a/go/internal/human/mocks/service.go b/go/internal/human/mocks/service.go new file mode 100644 index 0000000..274ef88 --- /dev/null +++ b/go/internal/human/mocks/service.go @@ -0,0 +1,131 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./service.go +// +// Generated by this command: +// +// mockgen -source ./service.go -destination ./mocks/service.go +// + +// Package mock_human is a generated GoMock package. +package mock_human + +import ( + context "context" + reflect "reflect" + time "time" + + human "github.com/flowy-live/llink/internal/human" + gomock "go.uber.org/mock/gomock" +) + +// MockService is a mock of Service interface. +type MockService struct { + ctrl *gomock.Controller + recorder *MockServiceMockRecorder + isgomock struct{} +} + +// MockServiceMockRecorder is the mock recorder for MockService. +type MockServiceMockRecorder struct { + mock *MockService +} + +// NewMockService creates a new mock instance. +func NewMockService(ctrl *gomock.Controller) *MockService { + mock := &MockService{ctrl: ctrl} + mock.recorder = &MockServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockService) EXPECT() *MockServiceMockRecorder { + return m.recorder +} + +// GetByEmail mocks base method. +func (m *MockService) GetByEmail(ctx context.Context, email string) (*human.Human, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetByEmail", ctx, email) + ret0, _ := ret[0].(*human.Human) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetByEmail indicates an expected call of GetByEmail. +func (mr *MockServiceMockRecorder) GetByEmail(ctx, email any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByEmail", reflect.TypeOf((*MockService)(nil).GetByEmail), ctx, email) +} + +// GetByID mocks base method. +func (m *MockService) GetByID(ctx context.Context, id string) (*human.Human, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetByID", ctx, id) + ret0, _ := ret[0].(*human.Human) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetByID indicates an expected call of GetByID. +func (mr *MockServiceMockRecorder) GetByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByID", reflect.TypeOf((*MockService)(nil).GetByID), ctx, id) +} + +// GetOrCreateByEmail mocks base method. +func (m *MockService) GetOrCreateByEmail(ctx context.Context, email string) (*human.Human, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOrCreateByEmail", ctx, email) + ret0, _ := ret[0].(*human.Human) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetOrCreateByEmail indicates an expected call of GetOrCreateByEmail. +func (mr *MockServiceMockRecorder) GetOrCreateByEmail(ctx, email any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrCreateByEmail", reflect.TypeOf((*MockService)(nil).GetOrCreateByEmail), ctx, email) +} + +// ListAll mocks base method. +func (m *MockService) ListAll(ctx context.Context) ([]*human.Human, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAll", ctx) + ret0, _ := ret[0].([]*human.Human) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListAll indicates an expected call of ListAll. +func (mr *MockServiceMockRecorder) ListAll(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAll", reflect.TypeOf((*MockService)(nil).ListAll), ctx) +} + +// UpdateEmailNotificationsEnabled mocks base method. +func (m *MockService) UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateEmailNotificationsEnabled", ctx, id, enabled) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateEmailNotificationsEnabled indicates an expected call of UpdateEmailNotificationsEnabled. +func (mr *MockServiceMockRecorder) UpdateEmailNotificationsEnabled(ctx, id, enabled any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateEmailNotificationsEnabled", reflect.TypeOf((*MockService)(nil).UpdateEmailNotificationsEnabled), ctx, id, enabled) +} + +// UpdateLastEmailNotificationSentAt mocks base method. +func (m *MockService) UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateLastEmailNotificationSentAt", ctx, id, t) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateLastEmailNotificationSentAt indicates an expected call of UpdateLastEmailNotificationSentAt. +func (mr *MockServiceMockRecorder) UpdateLastEmailNotificationSentAt(ctx, id, t any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateLastEmailNotificationSentAt", reflect.TypeOf((*MockService)(nil).UpdateLastEmailNotificationSentAt), ctx, id, t) +} diff --git a/go/internal/human/service.go b/go/internal/human/service.go index 2180443..1a28421 100644 --- a/go/internal/human/service.go +++ b/go/internal/human/service.go @@ -9,6 +9,8 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) +//go:generate go tool mockgen -source ./service.go -destination ./mocks/service.go + var ErrNotFound = errors.New("human not found") type Service interface { diff --git a/go/internal/network/service.go b/go/internal/network/service.go index 15ce8bd..861d29a 100644 --- a/go/internal/network/service.go +++ b/go/internal/network/service.go @@ -16,6 +16,7 @@ import ( pbaero "github.com/flowy-live/llink/genproto/aero" "github.com/flowy-live/llink/internal/billing" "github.com/flowy-live/llink/internal/constants" + "github.com/flowy-live/llink/internal/human" "github.com/flowy-live/llink/internal/livestore" "github.com/flowy-live/llink/internal/utils" ) @@ -27,6 +28,10 @@ var ErrInvalidHumanId = errors.New("invalid humanId") var ErrCapacityExceeded = errors.New("active stream capacity exceeded") var ErrInvalidRetentionHours = errors.New("message retention hours must be between 24 and 336") +type humanLookup interface { + GetByID(ctx context.Context, id string) (*human.Human, error) +} + type Service interface { Reader @@ -54,17 +59,25 @@ type Service interface { type serviceImpl struct { *readerImpl - aeroSvc pbaero.PrimaryClient - billingSvc billing.Service - pub livestore.MembershipPublisher + aeroSvc pbaero.PrimaryClient + billingSvc billing.Service + pub livestore.MembershipPublisher + humanLookup humanLookup } -func NewService(pool *pgxpool.Pool, aeroSvc pbaero.PrimaryClient, billingSvc billing.Service, pub livestore.MembershipPublisher) Service { +func NewService( + pool *pgxpool.Pool, + aeroSvc pbaero.PrimaryClient, + billingSvc billing.Service, + pub livestore.MembershipPublisher, + humanLookup humanLookup, +) Service { return &serviceImpl{ - readerImpl: newReader(pool), - aeroSvc: aeroSvc, - billingSvc: billingSvc, - pub: pub, + readerImpl: newReader(pool), + aeroSvc: aeroSvc, + billingSvc: billingSvc, + pub: pub, + humanLookup: humanLookup, } } @@ -223,6 +236,14 @@ func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, email } func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, humanId string) error { + network, err := s.repo.getByID(ctx, networkID) + if err != nil { + if errors.Is(err, errNotFound) { + return ErrNotFound + } + return err + } + normalized, err := utils.NormalizeEmail(email) if err != nil { return fmt.Errorf("%w: %w", ErrInvalidEmail, err) @@ -231,6 +252,11 @@ func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, hu return ErrInvalidHumanId } + prevMembersHumanIds, membersErr := s.repo.getMemberHumanIds(ctx, networkID) + if membersErr != nil { + flog.Warn("unable to get member human ids", "error", membersErr) + } + if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error { err := s.repo.deleteInvitation(ctx, tx, networkID, normalized) if err != nil { @@ -241,6 +267,40 @@ func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, hu return err } + if membersErr == nil && len(prevMembersHumanIds) > 0 { + emailRecipients := make([]string, 0, len(prevMembersHumanIds)) + for _, memberHumanId := range prevMembersHumanIds { + if memberHumanId != "" { + human, err := s.humanLookup.GetByID(ctx, memberHumanId) + if err != nil { + flog.Warn("unable to find human", "error", err, "humanId", memberHumanId) + continue + } + + if human.Email == "" || !human.EmailNotificationsEnabled { + continue + } + emailRecipients = append(emailRecipients, human.Email) + } + } + + if len(emailRecipients) > 0 { + newMemberEmailPrefix := strings.Split(normalized, "@")[0] + _, err = s.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{ + ToEmails: emailRecipients, + Subject: fmt.Sprintf("A new member has joined %s", network.Name), + TemplateData: &pbaero.ShootEmailRequest_SimpleHtmlData{ + SimpleHtmlData: &pbaero.SimpleHtmlData{ + Html: buildNewMemberHTML(network.Name, newMemberEmailPrefix), + }, + }, + }) + if err != nil { + flog.Warn("unable to send email notification", "email", email, "network", network.Name) + } + } + } + s.mirrorAddMembership(ctx, humanId, networkID) return nil } @@ -253,8 +313,24 @@ func (s *serviceImpl) RevokeInvitation(ctx context.Context, networkID, email str return s.repo.deleteInvitation(ctx, s.pool, networkID, normalized) } +const ( + emailWebAppURL = "https://llink.flowy.live" + emailDesktopURL = "llink://" + emailDownloadURL = "https://flowylabs.ai/llink/download" +) + +// emailDesktopFooter is the shared secondary line offering the desktop app. +// The web app is always the primary CTA (no install required), so desktop is +// kept quiet here and shared across templates so the two can't drift apart. +func emailDesktopFooter() string { + return fmt.Sprintf(` + + Prefer the desktop app? Open it or download here. + + `, emailDesktopURL, emailDownloadURL) +} + func buildInvitationHTML(networkName string) string { - const downloadURL = "https://flowylabs.ai/llink/download" safeName := html.EscapeString(networkName) return fmt.Sprintf(` @@ -265,25 +341,63 @@ func buildInvitationHTML(networkName string) string { - + %s
- You've been invited to join %s on Flowy.llink + You've been invited to join %s on Flowy.llink
- Launch (or download) the app to accept your invitation and connect with your team. + Open the app to accept your invitation. If you're new, you'll be prompted to create a free account first.
+ - Launch Flowy.llink + Open Flowy.llink (web)
-`, safeName, downloadURL) +`, safeName, emailWebAppURL, emailDesktopFooter()) +} + +// buildNewMemberHTML is an email template to notify other members that a new member has joined +func buildNewMemberHTML(networkName, newMemberEmailPrefix string) string { + safeName := html.EscapeString(networkName) + safeEmail := html.EscapeString(newMemberEmailPrefix) + return fmt.Sprintf(` + + + + + + +
+ + + + + + + + + + + %s +
+ A new member joined %s +
+ %s just joined your network on Flowy.llink. Say hello and bring them up to speed. +
+ + Open Flowy.llink (web) + +
+
+ +`, safeName, safeEmail, emailWebAppURL, emailDesktopFooter()) } diff --git a/go/internal/network/service_test.go b/go/internal/network/service_test.go index 9e06152..f2b5003 100644 --- a/go/internal/network/service_test.go +++ b/go/internal/network/service_test.go @@ -7,6 +7,8 @@ import ( pbaero "github.com/flowy-live/llink/genproto/aero" mock_billing "github.com/flowy-live/llink/internal/billing/mocks" + "github.com/flowy-live/llink/internal/human" + mock_human "github.com/flowy-live/llink/internal/human/mocks" mock_livestore "github.com/flowy-live/llink/internal/livestore/mocks" "github.com/flowy-live/llink/internal/network" "github.com/flowy-live/llink/internal/testhelper" @@ -41,7 +43,15 @@ func newTestService(t *testing.T) network.Service { mockPub.EXPECT().Add(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() mockPub.EXPECT().Remove(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() - return network.NewService(dbPool, mockAero, mockBilling, mockPub) + mockHuman := mock_human.NewMockService(ctrl) + mockHuman.EXPECT().GetByID(gomock.Any(), gomock.Any()).Return(&human.Human{ + ID: "test_human", + Email: "test@gmail.com", + EmailPrefix: "test", + EmailNotificationsEnabled: false, + }, nil).AnyTimes() + + return network.NewService(dbPool, mockAero, mockBilling, mockPub, mockHuman) } func TestNetworkService(t *testing.T) { diff --git a/js/desktop/README.md b/js/desktop/README.md new file mode 100644 index 0000000..68b0596 --- /dev/null +++ b/js/desktop/README.md @@ -0,0 +1,5 @@ +# Notes +## CORS for desktop app +In dev: each renderer process has it's own localhost port. The renderer process passes this in the `Origin` header for requests, and expects appropriate ACAO headers in the response. + +In packaged app: the renderer process does not include `Origin` header, so expects no extra ACAO headers from the server, otherwise the client would fail to accept responses. diff --git a/js/desktop/package.json b/js/desktop/package.json index 61068aa..d5ce88d 100644 --- a/js/desktop/package.json +++ b/js/desktop/package.json @@ -8,9 +8,10 @@ "scripts": { "start": "electron-forge start", "package:mac": "APP_ENV=prod electron-forge package --arch=arm64 && APP_ENV=prod electron-forge package --arch=x64", + "package:win": "cross-env APP_ENV=prod electron-forge package --platform=win32 --arch=x64", "make:mac": "APP_ENV=prod electron-forge make --arch=arm64 && APP_ENV=prod electron-forge make --arch=x64", - "make:win": "electron-forge make --platform=win32 --arch=x64", - "publish:win": "electron-forge publish --platform=win32 --arch=x64", + "make:win": "cross-env APP_ENV=prod electron-forge make --platform=win32 --arch=x64", + "publish:win": "cross-env APP_ENV=prod electron-forge publish --platform=win32 --arch=x64", "publish:mac": "echo '\n⚠️ Have you bumped the version in package.json? (current: '$(node -p \"require('./package.json').version\")') [y/N]' && read -r answer && [ \"$answer\" = \"y\" ] && APP_ENV=prod electron-forge publish --arch=arm64 && APP_ENV=prod electron-forge publish --arch=x64", "invalidate-gcs-cache": "gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/darwin/arm64/RELEASES.json && gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/darwin/x64/RELEASES.json && gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/win32/x64/RELEASES", "lint": "eslint --ext .ts,.tsx .", diff --git a/js/desktop/src/App.tsx b/js/desktop/src/App.tsx index b31e7c4..5a8da62 100644 --- a/js/desktop/src/App.tsx +++ b/js/desktop/src/App.tsx @@ -52,6 +52,19 @@ const App = () => { ); }; +function DeepLinkNavigationListener() { + const navigate = useNavigate(); + + useEffect(() => { + window.electronDeepLink.getPending().then((path) => { + if (path) navigate(path); + }); + return window.electronDeepLink.onNavigate((path) => navigate(path)); + }, [navigate]); + + return null; +} + function AutoplayNavigationListener() { const navigate = useNavigate(); @@ -68,6 +81,7 @@ function AuthenticatedApp() { return ( + diff --git a/js/desktop/src/electron.d.ts b/js/desktop/src/electron.d.ts index aceb98c..c3972f1 100644 --- a/js/desktop/src/electron.d.ts +++ b/js/desktop/src/electron.d.ts @@ -44,6 +44,10 @@ declare global { stop: () => void; onInit: (callback: () => void) => () => void; }; + electronDeepLink: { + getPending: () => Promise; + onNavigate: (callback: (path: string) => void) => () => void; + }; electronLink: { openExternal: (url: string) => Promise; }; diff --git a/js/desktop/src/main.ts b/js/desktop/src/main.ts index 38b4fae..1ecb397 100644 --- a/js/desktop/src/main.ts +++ b/js/desktop/src/main.ts @@ -4,14 +4,11 @@ import { desktopCapturer, ipcMain, screen, - session, shell, } from 'electron'; import path from 'node:path'; import started from 'electron-squirrel-startup'; import { updateElectronApp, UpdateSourceType } from 'update-electron-app'; - -import { appConfig } from './config/env'; import { safeHandle } from './main/ipc-utils'; import { initSentryMain } from './main/sentry'; @@ -26,16 +23,11 @@ if (app.isPackaged) { }); } -// Handle creating/removing shortcuts on Windows when installing/uninstalling. +// Handle creating/removing shortcuts on Windows when installing/updating/uninstalling. if (started) { app.quit(); } -// Set the dock icon for development mode on macOS. -if (process.platform === 'darwin' && !app.isPackaged) { - app.dock?.setIcon(path.join(__dirname, '../../assets/icon.png')); -} - // In dev, `LLINK_PROFILE=foo yarn start` spins up a second instance with an // isolated userData dir so it can coexist with the default one (separate auth, // cookies, leveldb locks). @@ -44,12 +36,10 @@ if (devProfile) { app.setPath('userData', `${app.getPath('userData')}-${devProfile}`); } -// Single-instance lock: on Windows/Linux, clicking a llink:// URL launches a new -// process. The lock makes the losing instance quit and fires `second-instance` on -// the primary, so we focus the existing window instead of spawning a duplicate. -// macOS uses `open-url` instead and doesn't need this, but the lock is harmless. -// Skip the lock when running a named dev profile — those instances are meant to -// run alongside the default one. +// NOTE: on Windows/Linux, clicking a llink:// URL launches a new process. On macOS, +// `open-url` focuses existing instance of an application. + +// Prevent running multiple instances of app, except when in development if (!devProfile && !app.requestSingleInstanceLock()) { app.quit(); } @@ -63,6 +53,8 @@ if (!app.isDefaultProtocolClient('llink')) { let mainWindow: BrowserWindow | null = null; let autoplayWindow: BrowserWindow | null = null; +let pendingDeepLink: string | null = null; +let rendererReady = false; let huddleWindow: BrowserWindow | null = null; let screenRecordWindow: BrowserWindow | null = null; @@ -88,10 +80,7 @@ function hardenWindow(win: BrowserWindow) { const isZoom = cmdOrCtrl && (key === '=' || key === '+' || key === '-' || key === '0'); - if (app.isPackaged && (isDevtools || isReload)) { - event.preventDefault(); - } - if (isZoom) { + if (app.isPackaged && (isDevtools || isReload || isZoom)) { event.preventDefault(); } }); @@ -132,6 +121,9 @@ const createWindow = () => { webPreferences: { preload: path.join(__dirname, 'preload.js'), backgroundThrottling: false, + webSecurity: true, + contextIsolation: true, + nodeIntegration: false, }, }); hardenWindow(mainWindow); @@ -433,40 +425,46 @@ ipcMain.on( }, ); -// This method will be called when Electron has finished -// initialization and is ready to create browser windows. -// Some APIs can only be used after this event occurs. +// Extracts the in-app route path from a llink:// URL. +// llink://networkId/streamId → /networkId/streamId +// llink:// or llink://open → / (root) +function deepLinkPath(url: string): string | null { + try { + const parsed = new URL(url); + if (parsed.protocol !== 'llink:') return null; + const host = parsed.hostname; + if (!host || host === 'open') return '/'; + return `/${host}${parsed.pathname}`; + } catch { + return null; + } +} + +function tryNavigateToDeepLink(url: string) { + // Before the renderer has mounted (cold start), there's no onNavigate + // listener yet — stash the link so the renderer can pull it via getPending. + if (!rendererReady || !mainWindow) { + pendingDeepLink = url; + return; + } + + const path = deepLinkPath(url); + if (!path) return; + + focusMainWindow(); + mainWindow.webContents.send('deep-link:navigate', path); +} + app.on('ready', () => { - // Allow CORS for API requests from the renderer process. - // The server doesn't handle OPTIONS preflight, so we intercept at the - // Electron network layer: inject CORS headers and return 200 for preflight. - session.defaultSession.webRequest.onHeadersReceived( - { urls: [`${appConfig.orionUrl}/*`, 'https://storage.googleapis.com/*'] }, - (details, callback) => { - const headers = { ...details.responseHeaders }; - headers['access-control-allow-origin'] = ['*']; - headers['access-control-allow-headers'] = [ - 'Content-Type', - 'Authorization', - ]; - headers['access-control-allow-methods'] = [ - 'GET', - 'POST', - 'PUT', - 'DELETE', - 'OPTIONS', - ]; - - if (details.method === 'OPTIONS') { - callback({ responseHeaders: headers, statusLine: 'HTTP/1.1 200 OK' }); - } else { - callback({ responseHeaders: headers }); - } - }, - ); - createWindow(); createAutoplayWindow(); + + // On Windows/Linux, a cold-start llink:// click passes the URL as a process argument. + // macOS cold start is handled via open-url, which fires after ready. + const coldStartUrl = process.argv.find((arg) => arg.startsWith('llink://')); + if (coldStartUrl) { + tryNavigateToDeepLink(coldStartUrl); + } }); app.on('window-all-closed', () => { @@ -489,20 +487,27 @@ function focusMainWindow() { mainWindow.focus(); } -// macOS delivers llink:// URLs via this event, both when the app is already -// running and on cold start (after `ready`). We prevent the default to silence -// Electron's warning and focus the window — OS-level focus alone won't restore -// a hidden or minimized window. Cold start is handled by createWindow(). -app.on('open-url', (event) => { +// macOS: fired on cold start and when app is already running. +app.on('open-url', (event, url) => { event.preventDefault(); - focusMainWindow(); + tryNavigateToDeepLink(url); // stashes if the renderer isn't ready yet (cold start) or pushes live otherwise. }); // Windows/Linux: the OS launches a second process with the URL in argv; the -// single-instance lock diverts it here on the primary instance. -app.on('second-instance', () => { - focusMainWindow(); +// single-instance lock diverts it here, executed on the primary instance main process. +app.on('second-instance', (_event, argv) => { + const url = argv.find((arg) => arg.startsWith('llink://')); + if (url) { + tryNavigateToDeepLink(url); + } else { + focusMainWindow(); + } }); -// In this file you can include the rest of your app's specific main process -// code. You can also put them in separate files and import them here. +// Renderer pulls any pending deep link on mount (cold-start case). +safeHandle('deep-link:get-pending', () => { + rendererReady = true; + const url = pendingDeepLink; + pendingDeepLink = null; + return url ? deepLinkPath(url) : null; +}); diff --git a/js/desktop/src/preload.ts b/js/desktop/src/preload.ts index 2cad5ca..735d66c 100644 --- a/js/desktop/src/preload.ts +++ b/js/desktop/src/preload.ts @@ -83,6 +83,19 @@ contextBridge.exposeInMainWorld('electronScreenRecord', { }, }); +contextBridge.exposeInMainWorld('electronDeepLink', { + getPending: () => + ipcRenderer.invoke('deep-link:get-pending') as Promise, + onNavigate: (callback: (path: string) => void) => { + const handler = (_event: Electron.IpcRendererEvent, path: string) => + callback(path); + ipcRenderer.on('deep-link:navigate', handler); + return () => { + ipcRenderer.removeListener('deep-link:navigate', handler); + }; + }, +}); + contextBridge.exposeInMainWorld('electronLink', { openExternal: (url: string) => ipcRenderer.invoke('link:open-external', url), });