Implement membership notifications and deep-link handling for Electron desktop app #246

Merged
talksik merged 11 commits from free-monkey into main 2026-06-09 15:04:19 +00:00
13 changed files with 384 additions and 79 deletions
+1
View File
@@ -4,3 +4,4 @@ build/
.cache/
compile_commands.json
CMakeLists.txt.user
tags
+5
View File
@@ -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:
+1 -1
View File
@@ -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"),
+131
View File
@@ -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)
}
+2
View File
@@ -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 {
+128 -14
View File
@@ -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(`<tr>
<td style="font-size:13px;color:#888888;">
Prefer the desktop app? <a href="%s" style="color:#111111;">Open it</a> or <a href="%s" style="color:#111111;">download here</a>.
</td>
</tr>`, emailDesktopURL, emailDownloadURL)
}
func buildInvitationHTML(networkName string) string {
const downloadURL = "https://flowylabs.ai/llink/download"
safeName := html.EscapeString(networkName)
return fmt.Sprintf(`<!DOCTYPE html>
<html>
@@ -265,25 +341,63 @@ func buildInvitationHTML(networkName string) string {
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="max-width:480px;background-color:#ffffff;border-radius:12px;padding:40px;">
<tr>
<td style="font-size:22px;font-weight:600;color:#111111;padding-bottom:16px;">
You've been invited to join %s on Flowy.llink
You&#39;ve been invited to join %s on Flowy.llink
</td>
</tr>
<tr>
<td style="font-size:15px;line-height:1.5;color:#444444;padding-bottom:32px;">
Launch (or download) the app to accept your invitation and connect with your team.
Open the app to accept your invitation. If you&#39;re new, you&#39;ll be prompted to create a free account first.
</td>
</tr>
<tr>
<td>
<td style="padding-bottom:24px;">
<a href="%s" style="display:inline-block;background-color:#111111;color:#ffffff;text-decoration:none;font-size:15px;font-weight:500;padding:12px 24px;border-radius:8px;">
Launch Flowy.llink
Open Flowy.llink (web)
</a>
</td>
</tr>
%s
</table>
</td>
</tr>
</table>
</body>
</html>`, safeName, downloadURL)
</html>`, 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(`<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background-color:#f5f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="background-color:#f5f5f7;padding:48px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="max-width:480px;background-color:#ffffff;border-radius:12px;padding:40px;">
<tr>
<td style="font-size:22px;font-weight:600;color:#111111;padding-bottom:16px;">
A new member joined %s
coderabbitai[bot] commented 2026-06-09 00:34:59 +00:00 (Migrated from github.com)
Review

⚠️ Potential issue | 🟠 Major | Quick win

Honor notification preferences before sending join emails.

Line 280 appends every resolved member email without checking EmailNotificationsEnabled (and without guarding blank emails). That can notify opted-out users and trigger ShootEmail with no valid recipients.

[sraise_placeholder]

Proposed fix
 	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
 				}
-
-				emailRecipients = append(emailRecipients, human.Email)
+				if !human.EmailNotificationsEnabled || strings.TrimSpace(human.Email) == "" {
+					continue
+				}
+				emailRecipients = append(emailRecipients, human.Email)
 			}
 		}
 
-		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)
+		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)
+			}
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@go/internal/network/service.go` around lines 270 - 296, When building
emailRecipients in the block that iterates prevMembersHumanIds (using
s.humanLookup.GetByID and appending to emailRecipients), only append human.Email
if it is non-empty and the human has EmailNotificationsEnabled == true (or
equivalent opt-in flag); after the loop, skip calling s.aeroSvc.ShootEmail (and
avoid calling buildNewMemberHTML) if emailRecipients is empty to prevent sending
with no recipients, and update the warning log in the error branch to include
network/name and recipient count for context.

Addressed in commit 60b1c3e

_⚠️ Potential issue_ | _🟠 Major_ | _⚡ Quick win_ **Honor notification preferences before sending join emails.** Line 280 appends every resolved member email without checking `EmailNotificationsEnabled` (and without guarding blank emails). That can notify opted-out users and trigger `ShootEmail` with no valid recipients. [sraise_placeholder] <details> <summary>Proposed fix</summary> ```diff 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 } - - emailRecipients = append(emailRecipients, human.Email) + if !human.EmailNotificationsEnabled || strings.TrimSpace(human.Email) == "" { + continue + } + emailRecipients = append(emailRecipients, human.Email) } } - 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) + 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) + } } } ``` </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@go/internal/network/service.go` around lines 270 - 296, When building emailRecipients in the block that iterates prevMembersHumanIds (using s.humanLookup.GetByID and appending to emailRecipients), only append human.Email if it is non-empty and the human has EmailNotificationsEnabled == true (or equivalent opt-in flag); after the loop, skip calling s.aeroSvc.ShootEmail (and avoid calling buildNewMemberHTML) if emailRecipients is empty to prevent sending with no recipients, and update the warning log in the error branch to include network/name and recipient count for context. ``` </details> <!-- fingerprinting:phantom:poseidon:hawk --> <!-- cr-comment:v1:179253e249b046480879cce2 --> <!-- This is an auto-generated comment by CodeRabbit --> ✅ Addressed in commit 60b1c3e
</td>
</tr>
<tr>
<td style="font-size:15px;line-height:1.5;color:#444444;padding-bottom:32px;">
<strong style="color:#111111;">%s</strong> just joined your network on Flowy.llink. Say hello and bring them up to speed.
</td>
</tr>
<tr>
<td style="padding-bottom:24px;">
<a href="%s" style="display:inline-block;background-color:#111111;color:#ffffff;text-decoration:none;font-size:15px;font-weight:500;padding:12px 24px;border-radius:8px;">
Open Flowy.llink (web)
</a>
</td>
</tr>
%s
</table>
</td>
</tr>
</table>
</body>
</html>`, safeName, safeEmail, emailWebAppURL, emailDesktopFooter())
}
+11 -1
View File
@@ -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) {
+5
View File
@@ -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.
coderabbitai[bot] commented 2026-06-09 00:34:59 +00:00 (Migrated from github.com)
Review

⚠️ Potential issue | 🟡 Minor | Quick win

Fix possessive pronoun.

Change "it's own" to "its own" (possessive, not contraction).

📝 Proposed fix
-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 dev: each renderer process has its own localhost port. The renderer process passes this in the `Origin` header for requests, and expects appropriate ACAO headers in the response.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

In dev: each renderer process has its own localhost port. The renderer process passes this in the `Origin` header for requests, and expects appropriate ACAO headers in the response.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/desktop/README.md` at line 3, Fix the possessive pronoun in the README
sentence that reads "In dev: each renderer process has it's own localhost port."
— change "it's" to the possessive "its" so the sentence becomes "In dev: each
renderer process has its own localhost port." Update the string in the README
where that exact sentence appears.
_⚠️ Potential issue_ | _🟡 Minor_ | _⚡ Quick win_ **Fix possessive pronoun.** Change "it's own" to "its own" (possessive, not contraction). <details> <summary>📝 Proposed fix</summary> ```diff -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 dev: each renderer process has its own localhost port. The renderer process passes this in the `Origin` header for requests, and expects appropriate ACAO headers in the response. ``` </details> <!-- suggestion_start --> <details> <summary>📝 Committable suggestion</summary> > ‼️ **IMPORTANT** > Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. ```suggestion In dev: each renderer process has its own localhost port. The renderer process passes this in the `Origin` header for requests, and expects appropriate ACAO headers in the response. ``` </details> <!-- suggestion_end --> <details> <summary>🤖 Prompt for AI Agents</summary> ``` Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@js/desktop/README.md` at line 3, Fix the possessive pronoun in the README sentence that reads "In dev: each renderer process has it's own localhost port." — change "it's" to the possessive "its" so the sentence becomes "In dev: each renderer process has its own localhost port." Update the string in the README where that exact sentence appears. ``` </details> <!-- fingerprinting:phantom:poseidon:puma --> <!-- cr-comment:v1:a04e62ddae6a345cfe823d8a --> <!-- This is an auto-generated comment by CodeRabbit -->
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.
+3 -2
View File
@@ -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 .",
+14
View File
@@ -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 (
<RouterShell>
<AutoplayNavigationListener />
<DeepLinkNavigationListener />
<InAppAutoplayCard />
<RouteErrorBoundary>
<Routes>
1
+4
View File
@@ -44,6 +44,10 @@ declare global {
stop: () => void;
onInit: (callback: () => void) => () => void;
};
electronDeepLink: {
getPending: () => Promise<string | null>;
onNavigate: (callback: (path: string) => void) => () => void;
};
electronLink: {
openExternal: (url: string) => Promise<void>;
};
+66 -61
View File
@@ -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;
});
+13
View File
@@ -83,6 +83,19 @@ contextBridge.exposeInMainWorld('electronScreenRecord', {
},
});
contextBridge.exposeInMainWorld('electronDeepLink', {
getPending: () =>
ipcRenderer.invoke('deep-link:get-pending') as Promise<string | null>,
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),
});