Implement membership notifications and deep-link handling for Electron desktop app #246
@@ -4,3 +4,4 @@ build/
|
|||||||
.cache/
|
.cache/
|
||||||
compile_commands.json
|
compile_commands.json
|
||||||
CMakeLists.txt.user
|
CMakeLists.txt.user
|
||||||
|
tags
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ tasks:
|
|||||||
- task: genproto
|
- task: genproto
|
||||||
- go generate ./...
|
- go generate ./...
|
||||||
|
|
||||||
|
format:
|
||||||
|
desc: Format go files
|
||||||
|
cmds:
|
||||||
|
- go fmt ./...
|
||||||
|
|
||||||
genproto:
|
genproto:
|
||||||
desc: Generate Go code from .proto files via docker
|
desc: Generate Go code from .proto files via docker
|
||||||
vars:
|
vars:
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer firestoreClient.Close()
|
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)
|
particleSvc := particle.NewService(db.Pool(), networkSvc)
|
||||||
depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{
|
depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{
|
||||||
GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
|
GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"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")
|
var ErrNotFound = errors.New("human not found")
|
||||||
|
|
||||||
type Service interface {
|
type Service interface {
|
||||||
|
|||||||
+128
-14
@@ -16,6 +16,7 @@ import (
|
|||||||
pbaero "github.com/flowy-live/llink/genproto/aero"
|
pbaero "github.com/flowy-live/llink/genproto/aero"
|
||||||
"github.com/flowy-live/llink/internal/billing"
|
"github.com/flowy-live/llink/internal/billing"
|
||||||
"github.com/flowy-live/llink/internal/constants"
|
"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/livestore"
|
||||||
"github.com/flowy-live/llink/internal/utils"
|
"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 ErrCapacityExceeded = errors.New("active stream capacity exceeded")
|
||||||
var ErrInvalidRetentionHours = errors.New("message retention hours must be between 24 and 336")
|
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 {
|
type Service interface {
|
||||||
Reader
|
Reader
|
||||||
|
|
||||||
@@ -54,17 +59,25 @@ type Service interface {
|
|||||||
|
|
||||||
type serviceImpl struct {
|
type serviceImpl struct {
|
||||||
*readerImpl
|
*readerImpl
|
||||||
aeroSvc pbaero.PrimaryClient
|
aeroSvc pbaero.PrimaryClient
|
||||||
billingSvc billing.Service
|
billingSvc billing.Service
|
||||||
pub livestore.MembershipPublisher
|
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{
|
return &serviceImpl{
|
||||||
readerImpl: newReader(pool),
|
readerImpl: newReader(pool),
|
||||||
aeroSvc: aeroSvc,
|
aeroSvc: aeroSvc,
|
||||||
billingSvc: billingSvc,
|
billingSvc: billingSvc,
|
||||||
pub: pub,
|
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 {
|
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)
|
normalized, err := utils.NormalizeEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%w: %w", ErrInvalidEmail, err)
|
return fmt.Errorf("%w: %w", ErrInvalidEmail, err)
|
||||||
@@ -231,6 +252,11 @@ func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, hu
|
|||||||
return ErrInvalidHumanId
|
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 {
|
if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
|
||||||
err := s.repo.deleteInvitation(ctx, tx, networkID, normalized)
|
err := s.repo.deleteInvitation(ctx, tx, networkID, normalized)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -241,6 +267,40 @@ func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, hu
|
|||||||
return err
|
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)
|
s.mirrorAddMembership(ctx, humanId, networkID)
|
||||||
return nil
|
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)
|
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 {
|
func buildInvitationHTML(networkName string) string {
|
||||||
const downloadURL = "https://flowylabs.ai/llink/download"
|
|
||||||
safeName := html.EscapeString(networkName)
|
safeName := html.EscapeString(networkName)
|
||||||
return fmt.Sprintf(`<!DOCTYPE html>
|
return fmt.Sprintf(`<!DOCTYPE html>
|
||||||
<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;">
|
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="max-width:480px;background-color:#ffffff;border-radius:12px;padding:40px;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="font-size:22px;font-weight:600;color:#111111;padding-bottom:16px;">
|
<td style="font-size:22px;font-weight:600;color:#111111;padding-bottom:16px;">
|
||||||
You've been invited to join %s on Flowy.llink
|
You've been invited to join %s on Flowy.llink
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="font-size:15px;line-height:1.5;color:#444444;padding-bottom:32px;">
|
<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're new, you'll be prompted to create a free account first.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<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;">
|
<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>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
%s
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</body>
|
</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
|
||||||
|
|
|||||||
|
</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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
|
|
||||||
pbaero "github.com/flowy-live/llink/genproto/aero"
|
pbaero "github.com/flowy-live/llink/genproto/aero"
|
||||||
mock_billing "github.com/flowy-live/llink/internal/billing/mocks"
|
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"
|
mock_livestore "github.com/flowy-live/llink/internal/livestore/mocks"
|
||||||
"github.com/flowy-live/llink/internal/network"
|
"github.com/flowy-live/llink/internal/network"
|
||||||
"github.com/flowy-live/llink/internal/testhelper"
|
"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().Add(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
|
||||||
mockPub.EXPECT().Remove(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: "[email protected]",
|
||||||
|
EmailPrefix: "test",
|
||||||
|
EmailNotificationsEnabled: false,
|
||||||
|
}, nil).AnyTimes()
|
||||||
|
|
||||||
|
return network.NewService(dbPool, mockAero, mockBilling, mockPub, mockHuman)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNetworkService(t *testing.T) {
|
func TestNetworkService(t *testing.T) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
⚠️ Potential issue | 🟡 Minor | ⚡ Quick win Fix possessive pronoun. Change "it's own" to "its own" (possessive, not contraction). 📝 Proposed fix📝 Committable suggestion
🤖 Prompt for AI Agents_⚠️ 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.
|
||||||
@@ -8,9 +8,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron-forge start",
|
"start": "electron-forge start",
|
||||||
"package:mac": "APP_ENV=prod electron-forge package --arch=arm64 && APP_ENV=prod electron-forge package --arch=x64",
|
"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: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",
|
"make:win": "cross-env APP_ENV=prod electron-forge make --platform=win32 --arch=x64",
|
||||||
"publish:win": "electron-forge publish --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",
|
"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",
|
"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 .",
|
"lint": "eslint --ext .ts,.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() {
|
function AutoplayNavigationListener() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -68,6 +81,7 @@ function AuthenticatedApp() {
|
|||||||
return (
|
return (
|
||||||
<RouterShell>
|
<RouterShell>
|
||||||
<AutoplayNavigationListener />
|
<AutoplayNavigationListener />
|
||||||
|
<DeepLinkNavigationListener />
|
||||||
<InAppAutoplayCard />
|
<InAppAutoplayCard />
|
||||||
<RouteErrorBoundary>
|
<RouteErrorBoundary>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
Vendored
+4
@@ -44,6 +44,10 @@ declare global {
|
|||||||
stop: () => void;
|
stop: () => void;
|
||||||
onInit: (callback: () => void) => () => void;
|
onInit: (callback: () => void) => () => void;
|
||||||
};
|
};
|
||||||
|
electronDeepLink: {
|
||||||
|
getPending: () => Promise<string | null>;
|
||||||
|
onNavigate: (callback: (path: string) => void) => () => void;
|
||||||
|
};
|
||||||
electronLink: {
|
electronLink: {
|
||||||
openExternal: (url: string) => Promise<void>;
|
openExternal: (url: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|||||||
+66
-61
@@ -4,14 +4,11 @@ import {
|
|||||||
desktopCapturer,
|
desktopCapturer,
|
||||||
ipcMain,
|
ipcMain,
|
||||||
screen,
|
screen,
|
||||||
session,
|
|
||||||
shell,
|
shell,
|
||||||
} from 'electron';
|
} from 'electron';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import started from 'electron-squirrel-startup';
|
import started from 'electron-squirrel-startup';
|
||||||
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
|
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
|
||||||
|
|
||||||
import { appConfig } from './config/env';
|
|
||||||
import { safeHandle } from './main/ipc-utils';
|
import { safeHandle } from './main/ipc-utils';
|
||||||
import { initSentryMain } from './main/sentry';
|
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) {
|
if (started) {
|
||||||
app.quit();
|
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
|
// 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,
|
// isolated userData dir so it can coexist with the default one (separate auth,
|
||||||
// cookies, leveldb locks).
|
// cookies, leveldb locks).
|
||||||
@@ -44,12 +36,10 @@ if (devProfile) {
|
|||||||
app.setPath('userData', `${app.getPath('userData')}-${devProfile}`);
|
app.setPath('userData', `${app.getPath('userData')}-${devProfile}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single-instance lock: on Windows/Linux, clicking a llink:// URL launches a new
|
// NOTE: on Windows/Linux, clicking a llink:// URL launches a new process. On macOS,
|
||||||
// process. The lock makes the losing instance quit and fires `second-instance` on
|
// `open-url` focuses existing instance of an application.
|
||||||
// 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.
|
// Prevent running multiple instances of app, except when in development
|
||||||
// Skip the lock when running a named dev profile — those instances are meant to
|
|
||||||
// run alongside the default one.
|
|
||||||
if (!devProfile && !app.requestSingleInstanceLock()) {
|
if (!devProfile && !app.requestSingleInstanceLock()) {
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
@@ -63,6 +53,8 @@ if (!app.isDefaultProtocolClient('llink')) {
|
|||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let autoplayWindow: BrowserWindow | null = null;
|
let autoplayWindow: BrowserWindow | null = null;
|
||||||
|
let pendingDeepLink: string | null = null;
|
||||||
|
let rendererReady = false;
|
||||||
let huddleWindow: BrowserWindow | null = null;
|
let huddleWindow: BrowserWindow | null = null;
|
||||||
let screenRecordWindow: BrowserWindow | null = null;
|
let screenRecordWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
@@ -88,10 +80,7 @@ function hardenWindow(win: BrowserWindow) {
|
|||||||
const isZoom =
|
const isZoom =
|
||||||
cmdOrCtrl && (key === '=' || key === '+' || key === '-' || key === '0');
|
cmdOrCtrl && (key === '=' || key === '+' || key === '-' || key === '0');
|
||||||
|
|
||||||
if (app.isPackaged && (isDevtools || isReload)) {
|
if (app.isPackaged && (isDevtools || isReload || isZoom)) {
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
if (isZoom) {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -132,6 +121,9 @@ const createWindow = () => {
|
|||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
backgroundThrottling: false,
|
backgroundThrottling: false,
|
||||||
|
webSecurity: true,
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
hardenWindow(mainWindow);
|
hardenWindow(mainWindow);
|
||||||
@@ -433,40 +425,46 @@ ipcMain.on(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// This method will be called when Electron has finished
|
// Extracts the in-app route path from a llink:// URL.
|
||||||
// initialization and is ready to create browser windows.
|
// llink://networkId/streamId → /networkId/streamId
|
||||||
// Some APIs can only be used after this event occurs.
|
// 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', () => {
|
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();
|
createWindow();
|
||||||
createAutoplayWindow();
|
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', () => {
|
app.on('window-all-closed', () => {
|
||||||
@@ -489,20 +487,27 @@ function focusMainWindow() {
|
|||||||
mainWindow.focus();
|
mainWindow.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// macOS delivers llink:// URLs via this event, both when the app is already
|
// macOS: fired on cold start and when app is already running.
|
||||||
// running and on cold start (after `ready`). We prevent the default to silence
|
app.on('open-url', (event, url) => {
|
||||||
// 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) => {
|
|
||||||
event.preventDefault();
|
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
|
// Windows/Linux: the OS launches a second process with the URL in argv; the
|
||||||
// single-instance lock diverts it here on the primary instance.
|
// single-instance lock diverts it here, executed on the primary instance main process.
|
||||||
app.on('second-instance', () => {
|
app.on('second-instance', (_event, argv) => {
|
||||||
focusMainWindow();
|
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
|
// Renderer pulls any pending deep link on mount (cold-start case).
|
||||||
// code. You can also put them in separate files and import them here.
|
safeHandle('deep-link:get-pending', () => {
|
||||||
|
rendererReady = true;
|
||||||
|
const url = pendingDeepLink;
|
||||||
|
pendingDeepLink = null;
|
||||||
|
return url ? deepLinkPath(url) : null;
|
||||||
|
});
|
||||||
|
|||||||
@@ -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', {
|
contextBridge.exposeInMainWorld('electronLink', {
|
||||||
openExternal: (url: string) => ipcRenderer.invoke('link:open-external', url),
|
openExternal: (url: string) => ipcRenderer.invoke('link:open-external', url),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user
⚠️ 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 triggerShootEmailwith no valid recipients.[sraise_placeholder]
Proposed fix
🤖 Prompt for AI Agents
✅ Addressed in commit
60b1c3e