From 69a857ad98a6cb2a97b3da89544fe42f147da91f Mon Sep 17 00:00:00 2001
From: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
Date: Fri, 19 Dec 2025 23:50:20 +0530
Subject: [PATCH] feat(companion): Multi-browser extension support (Chrome,
Firefox, Safari, Edge, Brave) and fix text node errors (#26063)
* feat(companion): add multi-browser OAuth support for extension
- Add browser-specific OAuth client ID and redirect URI configuration
- Support Chrome, Firefox, Safari, Edge, and Brave browsers
- Add build scripts for each browser target (ext:build-{browser})
- Fix Brave Shields compatibility for production builds
- Add EXTENSION_SETUP.md documentation
BREAKING CHANGE: None - backward compatible with existing Chrome config
* fix(companion): replace && conditional rendering with ternary operators
Replace all `{condition && ()}` patterns with explicit
`{condition ? () : null}` syntax across the companion app.
This fixes "Unexpected text node: . A text node cannot be a child of
a " errors that occur with react-native-css-interop when using
&& conditional rendering in React Native web builds.
Files updated:
- app/event-type-detail.tsx (16)
- app/booking-detail.tsx (10)
- app/(tabs)/bookings.tsx (8)
- app/(tabs)/availability.tsx (6)
- app/availability-detail.tsx (5)
- app/(tabs)/(event-types)/index.tsx (4)
- components/event-type-detail/tabs/LimitsTab.tsx (9)
- components/event-type-detail/tabs/BasicsTab.tsx (3)
- components/event-type-detail/tabs/AdvancedTab.tsx (3)
- components/event-type-detail/tabs/RecurringTab.tsx (1)
- components/event-type-detail/tabs/AvailabilityTab.tsx (1)
- components/event-type-list-item/EventTypeListItem.ios.tsx (3)
- components/BookingActionsModal.tsx (2)
- components/Tooltip.tsx (1)
- components/LocationsList.tsx (1)
- components/Header.tsx (1)
- components/EmptyScreen.tsx (1)
* correct command for edge
* dont break ci
* deslop ai
---
companion/.env.example | 32 ++
companion/app/(tabs)/(event-types)/index.tsx | 16 +-
companion/app/(tabs)/availability.tsx | 24 +-
companion/app/(tabs)/bookings.tsx | 207 ++++----
companion/app/availability-detail.tsx | 20 +-
companion/app/booking-detail.tsx | 36 +-
companion/app/event-type-detail.tsx | 66 +--
companion/components/BookingActionsModal.tsx | 8 +-
companion/components/EmptyScreen.tsx | 4 +-
companion/components/Header.tsx | 4 +-
companion/components/LocationsList.tsx | 4 +-
companion/components/Tooltip.tsx | 4 +-
.../event-type-detail/tabs/AdvancedTab.tsx | 12 +-
.../tabs/AvailabilityTab.tsx | 4 +-
.../event-type-detail/tabs/BasicsTab.tsx | 12 +-
.../event-type-detail/tabs/LimitsTab.tsx | 36 +-
.../event-type-detail/tabs/RecurringTab.tsx | 4 +-
.../EventTypeListItem.ios.tsx | 12 +-
.../EventTypeListItem.tsx | 12 +-
.../extension/entrypoints/background/index.ts | 458 +++++++++++++-----
companion/extension/entrypoints/content.ts | 45 +-
companion/extension/env.d.ts | 17 +
companion/package.json | 26 +-
companion/services/oauthService.ts | 126 ++++-
companion/wxt.config.ts | 76 ++-
25 files changed, 854 insertions(+), 411 deletions(-)
diff --git a/companion/.env.example b/companion/.env.example
index 4df8ceaad3..1dce561f4e 100644
--- a/companion/.env.example
+++ b/companion/.env.example
@@ -3,6 +3,38 @@ EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID=your_oauth_client_id_here
EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI=your_oauth_redirect_uri_here
+# =============================================================================
+# OAUTH REDIRECT URL FORMATS BY BROWSER
+# =============================================================================
+#
+# | Browser | Redirect URL Format | Notes |
+# |---------|------------------------------------------------------------------|----------------------------------------------------|
+# | Chrome | https://.chromiumapp.org/oauth/callback | Get ID from `chrome://extensions` |
+# | Brave | https://.chromiumapp.org/oauth/callback | Same extension package as Chrome |
+# | Edge | https://.chromiumapp.org/oauth/callback | Different ID from Chrome |
+# | Firefox | https://.extensions.allizom.org/oauth/callback | UUID from `browser.identity.getRedirectURL()` |
+# | Safari | https://..appextension/oauth/callback | Check Xcode project configuration |
+#
+# =============================================================================
+
+# =============================================================================
+# FIREFOX OAUTH CONFIG
+# =============================================================================
+EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_FIREFOX=
+EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_FIREFOX=
+
+# =============================================================================
+# SAFARI OAUTH CONFIG
+# =============================================================================
+EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_SAFARI=
+EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_SAFARI=
+
+# =============================================================================
+# EDGE OAUTH CONFIG
+# =============================================================================
+EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_EDGE=
+EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_EDGE=
+
# ===========================================
# CACHE CONFIGURATION (all values in MINUTES)
# ===========================================
diff --git a/companion/app/(tabs)/(event-types)/index.tsx b/companion/app/(tabs)/(event-types)/index.tsx
index c0492b94f5..941205d88d 100644
--- a/companion/app/(tabs)/(event-types)/index.tsx
+++ b/companion/app/(tabs)/(event-types)/index.tsx
@@ -690,17 +690,17 @@ export default function EventTypes() {
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
>
- {selectedEventType && (
+ {selectedEventType ? (
<>
{selectedEventType.title}
- {selectedEventType.description && (
+ {selectedEventType.description ? (
{normalizeMarkdown(selectedEventType.description)}
- )}
+ ) : null}
@@ -759,7 +759,7 @@ export default function EventTypes() {
>
- )}
+ ) : null}
@@ -844,12 +844,12 @@ export default function EventTypes() {
Delete Event Type
- {eventTypeToDelete && (
+ {eventTypeToDelete ? (
<>
This will permanently delete the "{eventTypeToDelete.title}" event type.
This action cannot be undone.
>
- )}
+ ) : null}
@@ -881,13 +881,13 @@ export default function EventTypes() {
{/* Toast for Web Platform */}
- {showToast && (
+ {showToast ? (
{toastMessage}
- )}
+ ) : null}
>
);
}
diff --git a/companion/app/(tabs)/availability.tsx b/companion/app/(tabs)/availability.tsx
index eb0fd129e4..1aa8f86882 100644
--- a/companion/app/(tabs)/availability.tsx
+++ b/companion/app/(tabs)/availability.tsx
@@ -273,11 +273,11 @@ export default function Availability() {
{schedule.name}
- {schedule.isDefault && (
+ {schedule.isDefault ? (
Default
- )}
+ ) : null}
{schedule.availability && schedule.availability.length > 0 ? (
@@ -360,7 +360,7 @@ export default function Availability() {
{/* Empty state - no schedules */}
- {showEmptyState && (
+ {showEmptyState ? (
- )}
+ ) : null}
{/* Search bar and content for non-empty states */}
- {!showEmptyState && (
+ {!showEmptyState ? (
<>
{/* Search empty state */}
- {showSearchEmptyState && (
+ {showSearchEmptyState ? (
- )}
+ ) : null}
{/* Schedules list */}
- {showList && (
+ {showList ? (
- )}
+ ) : null}
>
- )}
+ ) : null}
{/* Create Schedule Modal */}
{/* Set as Default - only show if not already default */}
- {selectedSchedule && !selectedSchedule.isDefault && (
+ {selectedSchedule && !selectedSchedule.isDefault ? (
<>
{
@@ -553,7 +553,7 @@ export default function Availability() {
>
- )}
+ ) : null}
{/* Duplicate */}
- {selectedEventTypeId !== null && (
+ {selectedEventTypeId !== null ? (
Filtered by {selectedEventTypeLabel || "event type"}
@@ -438,7 +438,7 @@ export default function Bookings() {
Clear filter
- )}
+ ) : null}
>
);
@@ -933,6 +933,46 @@ export default function Bookings() {
const isCancelled = item.status?.toUpperCase() === "CANCELLED";
const isRejected = item.status?.toUpperCase() === "REJECTED";
+ const getHostAndAttendeesDisplay = () => {
+ const hasHostOrAttendees =
+ (item.hosts && item.hosts.length > 0) ||
+ item.user ||
+ (item.attendees && item.attendees.length > 0);
+
+ if (!hasHostOrAttendees) return null;
+
+ const currentUserEmail = userInfo?.email?.toLowerCase();
+ const hostEmail = item.hosts?.[0]?.email?.toLowerCase() || item.user?.email?.toLowerCase();
+ const isCurrentUserHost = currentUserEmail && hostEmail && currentUserEmail === hostEmail;
+
+ const hostName = isCurrentUserHost
+ ? "You"
+ : item.hosts?.[0]?.name || item.hosts?.[0]?.email || item.user?.name || item.user?.email;
+
+ const attendeesDisplay =
+ item.attendees && item.attendees.length > 0
+ ? item.attendees.length === 1
+ ? item.attendees[0].name || item.attendees[0].email
+ : item.attendees
+ .slice(0, 2)
+ .map((att) => att.name || att.email)
+ .join(", ") +
+ (item.attendees.length > 2 ? ` and ${item.attendees.length - 2} more` : "")
+ : null;
+
+ if (hostName && attendeesDisplay) {
+ return `${hostName} and ${attendeesDisplay}`;
+ } else if (hostName) {
+ return hostName;
+ } else if (attendeesDisplay) {
+ return attendeesDisplay;
+ }
+ return null;
+ };
+
+ const hostAndAttendeesDisplay = getHostAndAttendeesDisplay();
+ const meetingInfo = getMeetingInfo(item.location);
+
return (
-
{/* Badges Row */}
- {isPending && (
+ {isPending ? (
Unconfirmed
- )}
+ ) : null}
-
{/* Title */}
{item.title}
-
{/* Description */}
- {item.description && (
+ {item.description ? (
"{item.description}"
- )}
-
+ ) : null}
{/* Host and Attendees */}
- {((item.hosts && item.hosts.length > 0) ||
- item.user ||
- (item.attendees && item.attendees.length > 0)) && (
-
- {(() => {
- // Check if current user is the host
- const currentUserEmail = userInfo?.email?.toLowerCase();
- const hostEmail =
- item.hosts?.[0]?.email?.toLowerCase() || item.user?.email?.toLowerCase();
- const isCurrentUserHost =
- currentUserEmail && hostEmail && currentUserEmail === hostEmail;
-
- // Get host display name
- const hostName = isCurrentUserHost
- ? "You"
- : item.hosts?.[0]?.name ||
- item.hosts?.[0]?.email ||
- item.user?.name ||
- item.user?.email;
-
- // Get attendees display
- const attendeesDisplay =
- item.attendees && item.attendees.length > 0
- ? item.attendees.length === 1
- ? item.attendees[0].name || item.attendees[0].email
- : item.attendees
- .slice(0, 2)
- .map((att) => att.name || att.email)
- .join(", ") +
- (item.attendees.length > 2 ? ` and ${item.attendees.length - 2} more` : "")
- : null;
-
- // Combine host and attendees
- if (hostName && attendeesDisplay) {
- return `${hostName} and ${attendeesDisplay}`;
- } else if (hostName) {
- return hostName;
- } else if (attendeesDisplay) {
- return attendeesDisplay;
- }
- return null;
- })()}
-
- )}
-
- {/* Meeting Link - only for video conferencing apps */}
- {(() => {
- const meetingInfo = getMeetingInfo(item.location);
- if (!meetingInfo) return null;
-
- return (
-
- {
- e.stopPropagation();
- try {
- await Linking.openURL(meetingInfo.cleanUrl);
- } catch {
- showErrorAlert("Error", "Failed to open meeting link. Please try again.");
- }
- }}
- >
- {meetingInfo.iconUrl ? (
-
- ) : (
-
- )}
- {meetingInfo.label}
-
-
- );
- })()}
+ {hostAndAttendeesDisplay ? (
+ {hostAndAttendeesDisplay}
+ ) : null}
+ {/* Meeting Link */}
+ {meetingInfo ? (
+
+ {
+ e.stopPropagation();
+ try {
+ await Linking.openURL(meetingInfo.cleanUrl);
+ } catch {
+ showErrorAlert("Error", "Failed to open meeting link. Please try again.");
+ }
+ }}
+ >
+ {meetingInfo.iconUrl ? (
+
+ ) : (
+
+ )}
+ {meetingInfo.label}
+
+
+ ) : null}
-
- {/* Action buttons row */}
- {/* Confirm and Reject buttons for unconfirmed bookings */}
- {isPending && (
+ {isPending ? (
<>
Reject
>
- )}
-
- {/* Three dots button */}
+ ) : null}
- )}
+ ) : null}
{/* Search empty state */}
- {showSearchEmptyState && (
+ {showSearchEmptyState ? (
- )}
+ ) : null}
{/* Bookings list */}
- {showList && (
+ {showList ? (
- )}
+ ) : null}
{/* Filter Modal */}
{eventType.title}
- {selectedEventTypeId === eventType.id && (
+ {selectedEventTypeId === eventType.id ? (
- )}
+ ) : null}
))}
- {eventTypes.length === 0 && (
+ {eventTypes.length === 0 ? (
No event types found
- )}
+ ) : null}
)}
@@ -1364,7 +1343,7 @@ export default function Bookings() {
}}
>
- {rescheduleBooking && (
+ {rescheduleBooking ? (
<>
Reschedule "{rescheduleBooking.title}"
@@ -1442,7 +1421,7 @@ export default function Bookings() {
Cancel
>
- )}
+ ) : null}
diff --git a/companion/app/availability-detail.tsx b/companion/app/availability-detail.tsx
index b09a64802d..42aa63d0fb 100644
--- a/companion/app/availability-detail.tsx
+++ b/companion/app/availability-detail.tsx
@@ -537,7 +537,7 @@ export default function AvailabilityDetail() {
>
{DAY_ABBREVIATIONS[dayIndex]}
- {isEnabled && firstSlot && (
+ {isEnabled && firstSlot ? (
@@ -563,16 +563,16 @@ export default function AvailabilityDetail() {
- )}
+ ) : null}
- {isEnabled && (
+ {isEnabled ? (
addTimeSlot(dayIndex)} className="p-1">
- )}
+ ) : null}
- {isEnabled && daySlots.length > 1 && (
+ {isEnabled && daySlots.length > 1 ? (
{daySlots.slice(1).map((slot, slotIndex) => (
@@ -616,7 +616,7 @@ export default function AvailabilityDetail() {
))}
- )}
+ ) : null}
);
})}
@@ -641,7 +641,7 @@ export default function AvailabilityDetail() {
Add dates when your availability changes from your daily hours.
- {overrides.length > 0 && (
+ {overrides.length > 0 ? (
{overrides.map((override, index) => (
))}
- )}
+ ) : null}
{/* Time Picker (only if not unavailable) */}
- {!isUnavailable && (
+ {!isUnavailable ? (
Which hours are you free?
@@ -919,7 +919,7 @@ export default function AvailabilityDetail() {
- )}
+ ) : null}
{/* Save Button */}
Who
{/* Show host from user field or hosts array */}
- {(booking.user || (booking.hosts && booking.hosts.length > 0)) && (
+ {booking.user || (booking.hosts && booking.hosts.length > 0) ? (
{booking.user ? (
@@ -412,8 +412,8 @@ export default function BookingDetail() {
))
) : null}
- )}
- {booking.attendees && booking.attendees.length > 0 && (
+ ) : null}
+ {booking.attendees && booking.attendees.length > 0 ? (
{booking.attendees.map((attendee, index) => (
0 ? "mt-4" : ""}`}>
@@ -431,11 +431,11 @@ export default function BookingDetail() {
))}
- )}
+ ) : null}
{/* Where Section */}
- {locationProvider && (
+ {locationProvider ? (
Where
{locationProvider.url ? (
@@ -443,14 +443,14 @@ export default function BookingDetail() {
onPress={handleJoinMeeting}
className="flex-row flex-wrap items-center"
>
- {locationProvider.iconUrl && (
+ {locationProvider.iconUrl ? (
- )}
+ ) : null}
{locationProvider.label}:
{locationProvider.url}
@@ -458,55 +458,55 @@ export default function BookingDetail() {
) : (
- {locationProvider.iconUrl && (
+ {locationProvider.iconUrl ? (
- )}
+ ) : null}
{locationProvider.label}
)}
- )}
+ ) : null}
{/* Recurring Event Section */}
- {(booking.recurringEventId || booking.recurringBookingUid) && (
+ {booking.recurringEventId || booking.recurringBookingUid ? (
Recurring Event
Every 2 weeks for 6 occurrences
- )}
+ ) : null}
{/* Description Section */}
- {booking.description && (
+ {booking.description ? (
Description
{booking.description}
- )}
+ ) : null}
{/* Join Meeting Button */}
- {locationProvider?.url && (
+ {locationProvider?.url ? (
- {locationProvider.iconUrl && (
+ {locationProvider.iconUrl ? (
- )}
+ ) : null}
Join {locationProvider.label}
- )}
+ ) : null}
diff --git a/companion/app/event-type-detail.tsx b/companion/app/event-type-detail.tsx
index d7970e2f83..edafbfc3fc 100644
--- a/companion/app/event-type-detail.tsx
+++ b/companion/app/event-type-detail.tsx
@@ -1157,7 +1157,7 @@ export default function EventTypeDetail() {
}}
contentContainerStyle={{ padding: 20, paddingBottom: 200 }}
>
- {activeTab === "basics" && (
+ {activeTab === "basics" ? (
- )}
+ ) : null}
{/* Duration Multi-Select Modal */}
{duration}
- {selectedDurations.includes(duration) && (
+ {selectedDurations.includes(duration) ? (
- )}
+ ) : null}
))}
@@ -1260,9 +1260,9 @@ export default function EventTypeDetail() {
>
{duration}
- {defaultDuration === duration && (
+ {defaultDuration === duration ? (
- )}
+ ) : null}
))}
@@ -1302,15 +1302,15 @@ export default function EventTypeDetail() {
>
{schedule.name}
- {schedule.isDefault && (
+ {schedule.isDefault ? (
Default
- )}
+ ) : null}
- {selectedSchedule?.id === schedule.id && (
+ {selectedSchedule?.id === schedule.id ? (
- )}
+ ) : null}
))}
@@ -1368,10 +1368,10 @@ export default function EventTypeDetail() {
>
{tz}
- {(selectedTimezone === tz ||
- (selectedScheduleDetails?.timeZone === tz && !selectedTimezone)) && (
+ {selectedTimezone === tz ||
+ (selectedScheduleDetails?.timeZone === tz && !selectedTimezone) ? (
- )}
+ ) : null}
))}
@@ -1410,9 +1410,9 @@ export default function EventTypeDetail() {
>
{option}
- {beforeEventBuffer === option && (
+ {beforeEventBuffer === option ? (
- )}
+ ) : null}
))}
@@ -1450,9 +1450,9 @@ export default function EventTypeDetail() {
>
{option}
- {afterEventBuffer === option && (
+ {afterEventBuffer === option ? (
- )}
+ ) : null}
))}
@@ -1490,9 +1490,9 @@ export default function EventTypeDetail() {
>
{option}
- {minimumNoticeUnit === option && (
+ {minimumNoticeUnit === option ? (
- )}
+ ) : null}
))}
@@ -1622,9 +1622,9 @@ export default function EventTypeDetail() {
>
{option}
- {slotInterval === option && (
+ {slotInterval === option ? (
- )}
+ ) : null}
))}
@@ -1662,16 +1662,16 @@ export default function EventTypeDetail() {
>
{option === "weekly" ? "week" : option === "monthly" ? "month" : "year"}
- {recurringFrequency === option && (
+ {recurringFrequency === option ? (
- )}
+ ) : null}
))}
- {activeTab === "availability" && (
+ {activeTab === "availability" ? (
- )}
+ ) : null}
- {activeTab === "limits" && (
+ {activeTab === "limits" ? (
- )}
+ ) : null}
- {activeTab === "advanced" && (
+ {activeTab === "advanced" ? (
- )}
+ ) : null}
- {activeTab === "recurring" && (
+ {activeTab === "recurring" ? (
- )}
+ ) : null}
- {activeTab === "other" && (
+ {activeTab === "other" ? (
Additional Settings
@@ -1884,7 +1884,7 @@ export default function EventTypeDetail() {
- )}
+ ) : null}
{/* Bottom Action Bar */}
diff --git a/companion/components/BookingActionsModal.tsx b/companion/components/BookingActionsModal.tsx
index a51e4a0009..57a460d615 100644
--- a/companion/components/BookingActionsModal.tsx
+++ b/companion/components/BookingActionsModal.tsx
@@ -163,7 +163,7 @@ export function BookingActionsModal({
{/* View Recordings */}
- {hasLocationUrl && (
+ {hasLocationUrl ? (
{
if (afterEventActionsDisabled) return;
@@ -184,10 +184,10 @@ export function BookingActionsModal({
View Recordings
- )}
+ ) : null}
{/* Meeting Session Details */}
- {hasLocationUrl && (
+ {hasLocationUrl ? (
{
if (afterEventActionsDisabled) return;
@@ -208,7 +208,7 @@ export function BookingActionsModal({
Meeting Session Details
- )}
+ ) : null}
{/* Mark as No-Show - disabled for upcoming and unconfirmed bookings */}
- {buttonText && onButtonPress && (
+ {buttonText && onButtonPress ? (
{buttonText}
- )}
+ ) : null}
);
diff --git a/companion/components/Header.tsx b/companion/components/Header.tsx
index c8cf54bbe5..1c967229b8 100644
--- a/companion/components/Header.tsx
+++ b/companion/components/Header.tsx
@@ -209,9 +209,9 @@ export function Header() {
{userProfile?.name || "User"}
- {userProfile?.email && (
+ {userProfile?.email ? (
{userProfile.email}
- )}
+ ) : null}
diff --git a/companion/components/LocationsList.tsx b/companion/components/LocationsList.tsx
index c49616b8ce..eac87c36c3 100644
--- a/companion/components/LocationsList.tsx
+++ b/companion/components/LocationsList.tsx
@@ -170,7 +170,7 @@ export const LocationsList: React.FC = ({
{location.displayName}
- {!disabled && (
+ {!disabled ? (
onRemove(location.id)}
className="ml-2 p-1"
@@ -178,7 +178,7 @@ export const LocationsList: React.FC = ({
>
- )}
+ ) : null}
{renderLocationInput(location)}
diff --git a/companion/components/Tooltip.tsx b/companion/components/Tooltip.tsx
index b5ff850c25..3dbb300069 100644
--- a/companion/components/Tooltip.tsx
+++ b/companion/components/Tooltip.tsx
@@ -39,7 +39,7 @@ export function Tooltip({ text, children }: TooltipProps) {
style={{ position: "relative" }}
>
{children}
- {showTooltip && (
+ {showTooltip ? (
- )}
+ ) : null}
);
}
diff --git a/companion/components/event-type-detail/tabs/AdvancedTab.tsx b/companion/components/event-type-detail/tabs/AdvancedTab.tsx
index a51a430f70..f16a6f6c34 100644
--- a/companion/components/event-type-detail/tabs/AdvancedTab.tsx
+++ b/companion/components/event-type-detail/tabs/AdvancedTab.tsx
@@ -291,7 +291,7 @@ export function AdvancedTab(props: AdvancedTabProps) {
{/* Seats Configuration - shown when enabled */}
- {props.seatsEnabled && (
+ {props.seatsEnabled ? (
{/* Number of seats per booking */}
@@ -342,14 +342,14 @@ export function AdvancedTab(props: AdvancedTabProps) {
: "border-[#D1D5DB] bg-white"
}`}
>
- {props.showAvailabilityCount && (
+ {props.showAvailabilityCount ? (
- )}
+ ) : null}
Show the number of available seats
- )}
+ ) : null}
@@ -397,7 +397,7 @@ export function AdvancedTab(props: AdvancedTabProps) {
{/* Timezone selector - shown when enabled */}
- {props.lockTimezone && (
+ {props.lockTimezone ? (
Timezone
- )}
+ ) : null}
- {props.selectedSchedule && (
+ {props.selectedSchedule ? (
<>
>
- )}
+ ) : null}
);
}
diff --git a/companion/components/event-type-detail/tabs/BasicsTab.tsx b/companion/components/event-type-detail/tabs/BasicsTab.tsx
index fc448ae236..b56dcc609b 100644
--- a/companion/components/event-type-detail/tabs/BasicsTab.tsx
+++ b/companion/components/event-type-detail/tabs/BasicsTab.tsx
@@ -84,7 +84,7 @@ export function BasicsTab(props: BasicsTabProps) {
{/* Duration Card */}
- {!props.allowMultipleDurations && (
+ {!props.allowMultipleDurations ? (
Duration
@@ -99,9 +99,9 @@ export function BasicsTab(props: BasicsTabProps) {
Minutes
- )}
+ ) : null}
- {props.allowMultipleDurations && (
+ {props.allowMultipleDurations ? (
<>
@@ -122,7 +122,7 @@ export function BasicsTab(props: BasicsTabProps) {
- {props.selectedDurations.length > 0 && (
+ {props.selectedDurations.length > 0 ? (
Default duration
- )}
+ ) : null}
>
- )}
+ ) : null}
Allow multiple durations
diff --git a/companion/components/event-type-detail/tabs/LimitsTab.tsx b/companion/components/event-type-detail/tabs/LimitsTab.tsx
index f7dc002629..7ab42f3799 100644
--- a/companion/components/event-type-detail/tabs/LimitsTab.tsx
+++ b/companion/components/event-type-detail/tabs/LimitsTab.tsx
@@ -183,7 +183,7 @@ export function LimitsTab(props: LimitsTabProps) {
},
]}
>
- {props.limitBookingFrequency && (
+ {props.limitBookingFrequency ? (
<>
{props.frequencyLimits.map((limit) => (
@@ -208,14 +208,14 @@ export function LimitsTab(props: LimitsTabProps) {
{limit.unit}
- {props.frequencyLimits.length > 1 && (
+ {props.frequencyLimits.length > 1 ? (
props.removeFrequencyLimit(limit.id)}
>
- )}
+ ) : null}
))}
Add Limit
>
- )}
+ ) : null}
@@ -282,7 +282,7 @@ export function LimitsTab(props: LimitsTabProps) {
},
]}
>
- {props.limitTotalDuration && (
+ {props.limitTotalDuration ? (
<>
{props.durationLimits.map((limit) => (
@@ -310,14 +310,14 @@ export function LimitsTab(props: LimitsTabProps) {
{limit.unit}
- {props.durationLimits.length > 1 && (
+ {props.durationLimits.length > 1 ? (
props.removeDurationLimit(limit.id)}
>
- )}
+ ) : null}
))}
Add Limit
>
- )}
+ ) : null}
@@ -350,7 +350,7 @@ export function LimitsTab(props: LimitsTabProps) {
thumbColor="#FFFFFF"
/>
- {props.maxActiveBookingsPerBooker && (
+ {props.maxActiveBookingsPerBooker ? (
- )}
+ ) : null}
{/* 5. Limit Future Bookings Card */}
@@ -404,7 +404,7 @@ export function LimitsTab(props: LimitsTabProps) {
thumbColor="#FFFFFF"
/>
- {props.limitFutureBookings && (
+ {props.limitFutureBookings ? (
{/* Rolling option */}
- {props.futureBookingType === "rolling" && (
+ {props.futureBookingType === "rolling" ? (
- )}
+ ) : null}
@@ -460,13 +460,13 @@ export function LimitsTab(props: LimitsTabProps) {
props.futureBookingType === "range" ? "border-[#007AFF]" : "border-[#C7C7CC]"
}`}
>
- {props.futureBookingType === "range" && (
+ {props.futureBookingType === "range" ? (
- )}
+ ) : null}
Within a date range
- {props.futureBookingType === "range" && (
+ {props.futureBookingType === "range" ? (
- )}
+ ) : null}
- )}
+ ) : null}
);
diff --git a/companion/components/event-type-detail/tabs/RecurringTab.tsx b/companion/components/event-type-detail/tabs/RecurringTab.tsx
index 7edd79dae8..17506e960b 100644
--- a/companion/components/event-type-detail/tabs/RecurringTab.tsx
+++ b/companion/components/event-type-detail/tabs/RecurringTab.tsx
@@ -64,7 +64,7 @@ export function RecurringTab({
{/* Recurring Configuration - shown when enabled */}
- {recurringEnabled && (
+ {recurringEnabled ? (
{/* Repeats Every */}
@@ -126,7 +126,7 @@ export function RecurringTab({
- )}
+ ) : null}
);
diff --git a/companion/components/event-type-list-item/EventTypeListItem.ios.tsx b/companion/components/event-type-list-item/EventTypeListItem.ios.tsx
index 9224dcc0af..cc01b013f9 100644
--- a/companion/components/event-type-list-item/EventTypeListItem.ios.tsx
+++ b/companion/components/event-type-list-item/EventTypeListItem.ios.tsx
@@ -87,11 +87,11 @@ export const EventTypeListItem = ({
{item.title}
- {item.description && (
+ {item.description ? (
{normalizeMarkdown(item.description)}
- )}
+ ) : null}
@@ -100,17 +100,17 @@ export const EventTypeListItem = ({
{(item.price != null && item.price > 0) || item.requiresConfirmation ? (
- {item.price != null && item.price > 0 && (
+ {item.price != null && item.price > 0 ? (
{item.currency || "$"}
{item.price}
- )}
- {item.requiresConfirmation && (
+ ) : null}
+ {item.requiresConfirmation ? (
Requires Confirmation
- )}
+ ) : null}
) : null}
diff --git a/companion/components/event-type-list-item/EventTypeListItem.tsx b/companion/components/event-type-list-item/EventTypeListItem.tsx
index 3bf3491869..2d080b7520 100644
--- a/companion/components/event-type-list-item/EventTypeListItem.tsx
+++ b/companion/components/event-type-list-item/EventTypeListItem.tsx
@@ -45,11 +45,11 @@ export const EventTypeListItem = ({
{item.title}
- {item.description && (
+ {item.description ? (
{normalizeMarkdown(item.description)}
- )}
+ ) : null}
@@ -58,17 +58,17 @@ export const EventTypeListItem = ({
{(item.price != null && item.price > 0) || item.requiresConfirmation ? (
- {item.price != null && item.price > 0 && (
+ {item.price != null && item.price > 0 ? (
{item.currency || "$"}
{item.price}
- )}
- {item.requiresConfirmation && (
+ ) : null}
+ {item.requiresConfirmation ? (
Requires Confirmation
- )}
+ ) : null}
) : null}
diff --git a/companion/extension/entrypoints/background/index.ts b/companion/extension/entrypoints/background/index.ts
index c390c33ee1..2eeb7702bc 100644
--- a/companion/extension/entrypoints/background/index.ts
+++ b/companion/extension/entrypoints/background/index.ts
@@ -4,6 +4,7 @@ import type { OAuthTokens } from "../../../services/oauthService";
const DEV_API_KEY = import.meta.env.EXPO_PUBLIC_CAL_API_KEY as string | undefined;
const IS_DEV_MODE = Boolean(DEV_API_KEY && DEV_API_KEY.length > 0);
+const BROWSER_TARGET = import.meta.env.BROWSER_TARGET || "chrome";
const devLog = {
log: (...args: unknown[]) => IS_DEV_MODE && console.log("[Cal.com]", ...args),
@@ -11,14 +12,136 @@ const devLog = {
error: (...args: unknown[]) => console.error("[Cal.com]", ...args),
};
+/**
+ * Browser type enum (inlined to avoid import issues in service worker)
+ */
+enum BrowserType {
+ Chrome = "chrome",
+ Firefox = "firefox",
+ Safari = "safari",
+ Edge = "edge",
+ Brave = "brave",
+ Unknown = "unknown",
+}
+
+/**
+ * Detect browser type based on user agent and APIs
+ */
+function detectBrowser(): BrowserType {
+ if (typeof navigator === "undefined") {
+ return BrowserType.Unknown;
+ }
+
+ const userAgent = navigator.userAgent.toLowerCase();
+
+ // Check for Brave
+ // @ts-ignore - Brave adds this to navigator
+ if (navigator.brave && typeof navigator.brave.isBrave === "function") {
+ return BrowserType.Brave;
+ }
+
+ // Check for Edge
+ if (userAgent.includes("edg/")) {
+ return BrowserType.Edge;
+ }
+
+ // Check for Firefox
+ if (userAgent.includes("firefox")) {
+ return BrowserType.Firefox;
+ }
+
+ // Check for Safari
+ if (
+ userAgent.includes("safari") &&
+ !userAgent.includes("chrome") &&
+ !userAgent.includes("chromium")
+ ) {
+ return BrowserType.Safari;
+ }
+
+ // Default to Chrome
+ if (userAgent.includes("chrome") || userAgent.includes("chromium")) {
+ return BrowserType.Chrome;
+ }
+
+ return BrowserType.Unknown;
+}
+
+/**
+ * Get browser display name for logging
+ */
+function getBrowserDisplayName(): string {
+ const browser = detectBrowser();
+ switch (browser) {
+ case BrowserType.Chrome:
+ return "Chrome";
+ case BrowserType.Firefox:
+ return "Firefox";
+ case BrowserType.Safari:
+ return "Safari";
+ case BrowserType.Edge:
+ return "Microsoft Edge";
+ case BrowserType.Brave:
+ return "Brave";
+ default:
+ return "Unknown Browser";
+ }
+}
+
+/**
+ * Cross-browser API helpers
+ * These provide unified access to browser extension APIs across Chrome, Firefox, Safari, and Edge
+ */
+
+// Get the appropriate browser API namespace
+function getBrowserAPI(): typeof chrome {
+ // @ts-ignore - Firefox/Safari use browser namespace
+ if (typeof browser !== "undefined" && browser.runtime) {
+ // @ts-ignore
+ return browser;
+ }
+ return chrome;
+}
+
+// Get identity API with cross-browser support
+function getIdentityAPI(): typeof chrome.identity | null {
+ const api = getBrowserAPI();
+ return api?.identity || null;
+}
+
+// Get storage API with cross-browser support
+function getStorageAPI(): typeof chrome.storage | null {
+ const api = getBrowserAPI();
+ return api?.storage || null;
+}
+
+// Get runtime API with cross-browser support
+function getRuntimeAPI(): typeof chrome.runtime | null {
+ const api = getBrowserAPI();
+ return api?.runtime || null;
+}
+
+// Get tabs API with cross-browser support
+function getTabsAPI(): typeof chrome.tabs | null {
+ const api = getBrowserAPI();
+ return api?.tabs || null;
+}
+
+// Get action API with cross-browser support
+function getActionAPI(): typeof chrome.action | null {
+ const api = getBrowserAPI();
+ // @ts-ignore - Some browsers use browserAction instead of action
+ return api?.action || api?.browserAction || null;
+}
+
// Check if the URL is a restricted page where content scripts can't run
function isRestrictedUrl(url: string | undefined): boolean {
if (!url) return true;
- // List of restricted URL patterns
+ // List of restricted URL patterns for all supported browsers
const restrictedPatterns = [
/^chrome:\/\//i, // Chrome internal pages (newtab, settings, extensions, etc.)
- /^chrome-extension:\/\//i, // Other extension pages
+ /^chrome-extension:\/\//i, // Chrome extension pages
/^edge:\/\//i, // Edge internal pages
/^about:/i, // about:blank, about:newtab, etc.
/^brave:\/\//i, // Brave internal pages
@@ -29,6 +152,9 @@ function isRestrictedUrl(url: string | undefined): boolean {
/^devtools:\/\//i, // DevTools pages
/^data:/i, // Data URLs
/^blob:/i, // Blob URLs
+ /^moz-extension:\/\//i, // Firefox extension pages
+ /^safari-extension:\/\//i, // Safari extension pages
+ /^safari-web-extension:\/\//i, // Safari web extension pages
];
return restrictedPatterns.some((pattern) => pattern.test(url));
@@ -36,160 +162,217 @@ function isRestrictedUrl(url: string | undefined): boolean {
// Open cal.com/app (Framer marketing page) in a new tab with auto-open parameter
function openAppPage(): void {
- chrome.tabs.create({ url: "https://cal.com/app?openExtension=true" });
+ const tabsAPI = getTabsAPI();
+ if (tabsAPI) {
+ tabsAPI.create({ url: "https://cal.com/app?openExtension=true" });
+ }
}
// @ts-ignore - WXT provides this globally
export default defineBackground(() => {
+ const browserType = detectBrowser();
+ const browserName = getBrowserDisplayName();
+
if (IS_DEV_MODE) {
devLog.log("DEV MODE - API Key authentication enabled for testing");
+ devLog.log(`Browser: ${browserName}, Target: ${BROWSER_TARGET}`);
}
- chrome.action.onClicked.addListener((tab) => {
- // Check if this is a restricted URL where content scripts can't run
- if (isRestrictedUrl(tab.url)) {
- devLog.log("Restricted URL detected, opening app page:", tab.url);
- openAppPage();
- return;
- }
+ const actionAPI = getActionAPI();
+ const runtimeAPI = getRuntimeAPI();
+ const tabsAPI = getTabsAPI();
+ const storageAPI = getStorageAPI();
- if (tab.id) {
- chrome.tabs.sendMessage(tab.id, { action: "icon-clicked" }, () => {
- // Ignore errors - expected on pages where content script hasn't loaded yet
- // The restricted URL check above handles pages where content scripts can't run
- void chrome.runtime.lastError;
- });
- }
- });
+ if (actionAPI) {
+ actionAPI.onClicked.addListener((tab) => {
+ // Check if this is a restricted URL where content scripts can't run
+ if (isRestrictedUrl(tab.url)) {
+ devLog.log("Restricted URL detected, opening app page:", tab.url);
+ openAppPage();
+ return;
+ }
- chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
- if (message.action === "fetch-event-types") {
- fetchEventTypes()
- .then((eventTypes) => sendResponse({ data: eventTypes }))
- .catch((error) => sendResponse({ error: error.message }));
- return true;
- }
-
- if (message.action === "start-extension-oauth") {
- const authUrl = message.authUrl as string;
- const state = new URL(authUrl).searchParams.get("state");
-
- if (state) {
- chrome.storage.local.set({ oauth_state: state }, () => {
- if (chrome.runtime.lastError) {
- devLog.warn("Failed to store OAuth state:", chrome.runtime.lastError.message);
- }
+ if (tab.id && tabsAPI) {
+ tabsAPI.sendMessage(tab.id, { action: "icon-clicked" }, () => {
+ // Ignore errors - expected on pages where content script hasn't loaded yet
+ // The restricted URL check above handles pages where content scripts can't run
+ const runtime = getRuntimeAPI();
+ if (runtime) void runtime.lastError;
});
}
+ });
+ }
- handleExtensionOAuth(authUrl)
- .then((responseUrl) => sendResponse({ success: true, responseUrl }))
- .catch((error) => sendResponse({ success: false, error: error.message }));
- return true;
- }
-
- if (message.action === "exchange-oauth-tokens") {
- handleTokenExchange(message.tokenRequest, message.tokenEndpoint, message.state)
- .then((tokens) => sendResponse({ success: true, tokens }))
- .catch((error) => sendResponse({ success: false, error: error.message }));
- return true;
- }
-
- if (message.action === "sync-oauth-tokens") {
- const tokens = message.tokens as OAuthTokens | null;
-
- if (isRateLimited()) {
- devLog.warn("Token sync rate limited");
- sendResponse({ success: false, error: "Rate limited. Please try again later." });
+ if (runtimeAPI) {
+ runtimeAPI.onMessage.addListener((message, sender, sendResponse) => {
+ if (message.action === "fetch-event-types") {
+ fetchEventTypes()
+ .then((eventTypes) => sendResponse({ data: eventTypes }))
+ .catch((error) => sendResponse({ error: error.message }));
return true;
}
- if (!tokens || !tokens.accessToken) {
- sendResponse({ success: false, error: "No valid tokens provided" });
+ if (message.action === "start-extension-oauth") {
+ const authUrl = message.authUrl as string;
+ const state = new URL(authUrl).searchParams.get("state");
+
+ if (state && storageAPI?.local) {
+ storageAPI.local.set({ oauth_state: state }, () => {
+ const runtime = getRuntimeAPI();
+ if (runtime?.lastError) {
+ devLog.warn("Failed to store OAuth state:", runtime.lastError.message);
+ }
+ });
+ }
+
+ handleExtensionOAuth(authUrl)
+ .then((responseUrl) => sendResponse({ success: true, responseUrl }))
+ .catch((error) => sendResponse({ success: false, error: error.message }));
return true;
}
- validateTokens(tokens)
- .then((isValid) => {
- if (!isValid) {
- devLog.warn("Token sync rejected: invalid tokens");
- sendResponse({ success: false, error: "Invalid tokens" });
- return;
- }
+ if (message.action === "exchange-oauth-tokens") {
+ handleTokenExchange(message.tokenRequest, message.tokenEndpoint, message.state)
+ .then((tokens) => sendResponse({ success: true, tokens }))
+ .catch((error) => sendResponse({ success: false, error: error.message }));
+ return true;
+ }
- recordTokenOperation();
- chrome.storage.local.set({ cal_oauth_tokens: JSON.stringify(tokens) }, () => {
- if (chrome.runtime.lastError) {
- devLog.error("Failed to sync OAuth tokens:", chrome.runtime.lastError.message);
- sendResponse({ success: false, error: chrome.runtime.lastError.message });
+ if (message.action === "sync-oauth-tokens") {
+ const tokens = message.tokens as OAuthTokens | null;
+
+ if (isRateLimited()) {
+ devLog.warn("Token sync rate limited");
+ sendResponse({ success: false, error: "Rate limited. Please try again later." });
+ return true;
+ }
+
+ if (!tokens || !tokens.accessToken) {
+ sendResponse({ success: false, error: "No valid tokens provided" });
+ return true;
+ }
+
+ validateTokens(tokens)
+ .then((isValid) => {
+ if (!isValid) {
+ devLog.warn("Token sync rejected: invalid tokens");
+ sendResponse({ success: false, error: "Invalid tokens" });
+ return;
+ }
+
+ recordTokenOperation();
+ if (storageAPI?.local) {
+ storageAPI.local.set({ cal_oauth_tokens: JSON.stringify(tokens) }, () => {
+ const runtime = getRuntimeAPI();
+ if (runtime?.lastError) {
+ devLog.error("Failed to sync OAuth tokens:", runtime.lastError.message);
+ sendResponse({ success: false, error: runtime.lastError.message });
+ } else {
+ devLog.log("OAuth tokens synced to storage (validated)");
+ sendResponse({ success: true });
+ }
+ });
+ }
+ })
+ .catch((error) => {
+ devLog.error("Token validation failed:", error);
+ sendResponse({ success: false, error: "Token validation failed" });
+ });
+
+ return true;
+ }
+
+ if (message.action === "clear-oauth-tokens") {
+ if (isRateLimited()) {
+ devLog.warn("Token clear rate limited");
+ sendResponse({ success: false, error: "Rate limited. Please try again later." });
+ return true;
+ }
+
+ recordTokenOperation();
+ if (storageAPI?.local) {
+ storageAPI.local.remove(["cal_oauth_tokens", "oauth_state"], () => {
+ const runtime = getRuntimeAPI();
+ if (runtime?.lastError) {
+ devLog.error("Failed to clear OAuth tokens:", runtime.lastError.message);
+ sendResponse({ success: false, error: runtime.lastError.message });
} else {
- devLog.log("OAuth tokens synced to chrome.storage.local (validated)");
+ devLog.log("OAuth tokens cleared from storage");
sendResponse({ success: true });
}
});
- })
- .catch((error) => {
- devLog.error("Token validation failed:", error);
- sendResponse({ success: false, error: "Token validation failed" });
- });
-
- return true;
- }
-
- if (message.action === "clear-oauth-tokens") {
- if (isRateLimited()) {
- devLog.warn("Token clear rate limited");
- sendResponse({ success: false, error: "Rate limited. Please try again later." });
+ }
return true;
}
- recordTokenOperation();
- chrome.storage.local.remove(["cal_oauth_tokens", "oauth_state"], () => {
- if (chrome.runtime.lastError) {
- devLog.error("Failed to clear OAuth tokens:", chrome.runtime.lastError.message);
- sendResponse({ success: false, error: chrome.runtime.lastError.message });
- } else {
- devLog.log("OAuth tokens cleared from chrome.storage.local");
- sendResponse({ success: true });
- }
- });
- return true;
- }
-
- return false;
- });
+ return false;
+ });
+ }
});
async function handleExtensionOAuth(authUrl: string): Promise {
- return new Promise((resolve, reject) => {
- if (!chrome.identity) {
- reject(new Error("Chrome identity API not available"));
- return;
+ const identityAPI = getIdentityAPI();
+ const browserType = detectBrowser();
+
+ if (!identityAPI) {
+ throw new Error(`Identity API not available in ${getBrowserDisplayName()}`);
+ }
+
+ if (IS_DEV_MODE && identityAPI.getRedirectURL) {
+ const expectedRedirectUrl = identityAPI.getRedirectURL();
+ const redirectUri = new URL(authUrl).searchParams.get("redirect_uri");
+
+ devLog.log("Starting OAuth flow");
+ devLog.log("Browser:", getBrowserDisplayName());
+ devLog.log("Expected redirect URL:", expectedRedirectUrl);
+ devLog.log("Auth URL redirect_uri:", redirectUri);
+
+ if (redirectUri && !redirectUri.startsWith(expectedRedirectUrl.replace(/\/$/, ""))) {
+ devLog.warn(
+ "MISMATCH! redirect_uri does not match expected URL.",
+ "\nExpected:",
+ expectedRedirectUrl,
+ "\nGot:",
+ redirectUri
+ );
}
+ }
- if (IS_DEV_MODE) {
- const expectedRedirectUrl = chrome.identity.getRedirectURL();
- const redirectUri = new URL(authUrl).searchParams.get("redirect_uri");
+ return new Promise((resolve, reject) => {
+ // Firefox and Safari use Promise-based API
+ if (browserType === BrowserType.Firefox || browserType === BrowserType.Safari) {
+ try {
+ // @ts-ignore - Firefox/Safari return Promises
+ const result = identityAPI.launchWebAuthFlow({ url: authUrl, interactive: true });
- devLog.log("Starting OAuth flow");
- devLog.log("Expected redirect URL:", expectedRedirectUrl);
- devLog.log("Auth URL redirect_uri:", redirectUri);
-
- if (redirectUri && !redirectUri.startsWith(expectedRedirectUrl.replace(/\/$/, ""))) {
- devLog.warn(
- "MISMATCH! redirect_uri does not match Chrome's expected URL.",
- "\nExpected:",
- expectedRedirectUrl,
- "\nGot:",
- redirectUri
- );
+ if (result && typeof result.then === "function") {
+ result
+ .then((responseUrl: string | undefined) => {
+ if (responseUrl) {
+ devLog.log("OAuth successful");
+ resolve(responseUrl);
+ } else {
+ devLog.error("OAuth flow returned no response URL");
+ reject(new Error("OAuth flow cancelled or failed"));
+ }
+ })
+ .catch((error: Error) => {
+ devLog.error("OAuth flow failed:", error.message);
+ reject(new Error(`OAuth flow failed: ${error.message}`));
+ });
+ return;
+ }
+ } catch {
+ // Fall through to callback-based API
}
}
- chrome.identity.launchWebAuthFlow({ url: authUrl, interactive: true }, (responseUrl) => {
- if (chrome.runtime.lastError) {
- devLog.error("OAuth flow failed:", chrome.runtime.lastError.message);
- reject(new Error(`OAuth flow failed: ${chrome.runtime.lastError.message}`));
+ // Chrome/Edge use callback-based API
+ identityAPI.launchWebAuthFlow({ url: authUrl, interactive: true }, (responseUrl) => {
+ const runtimeAPI = getRuntimeAPI();
+ if (runtimeAPI?.lastError) {
+ devLog.error("OAuth flow failed:", runtimeAPI.lastError.message);
+ reject(new Error(`OAuth flow failed: ${runtimeAPI.lastError.message}`));
} else if (responseUrl) {
devLog.log("OAuth successful");
resolve(responseUrl);
@@ -238,8 +421,11 @@ async function handleTokenExchange(
};
try {
- await chrome.storage.local.set({ cal_oauth_tokens: JSON.stringify(tokens) });
- devLog.log("OAuth tokens stored in chrome.storage.local");
+ const storageAPI = getStorageAPI();
+ if (storageAPI?.local) {
+ await storageAPI.local.set({ cal_oauth_tokens: JSON.stringify(tokens) });
+ devLog.log("OAuth tokens stored in storage");
+ }
} catch (storageError) {
devLog.error("Failed to store OAuth tokens:", storageError);
}
@@ -248,18 +434,24 @@ async function handleTokenExchange(
}
async function validateOAuthState(state: string): Promise {
+ const storageAPI = getStorageAPI();
+ if (!storageAPI?.local) {
+ devLog.warn("Storage API not available for state validation");
+ return;
+ }
+
try {
- const result = await chrome.storage.local.get(["oauth_state"]);
+ const result = await storageAPI.local.get(["oauth_state"]);
const storedState = result.oauth_state as string | undefined;
if (storedState && storedState !== state) {
- await chrome.storage.local.remove("oauth_state");
+ await storageAPI.local.remove("oauth_state");
devLog.error("State parameter mismatch - possible CSRF attack");
throw new Error("Invalid state parameter - possible CSRF attack");
}
if (storedState === state) {
- await chrome.storage.local.remove("oauth_state");
+ await storageAPI.local.remove("oauth_state");
}
} catch (error) {
if (error instanceof Error && error.message.includes("Invalid state parameter")) {
@@ -318,13 +510,17 @@ async function validateTokens(tokens: OAuthTokens): Promise {
}
async function getAuthHeader(): Promise {
- const result = await chrome.storage.local.get(["cal_oauth_tokens"]);
- const oauthTokens = result.cal_oauth_tokens
- ? (JSON.parse(result.cal_oauth_tokens as string) as OAuthTokens)
- : null;
+ const storageAPI = getStorageAPI();
- if (oauthTokens?.accessToken) {
- return `Bearer ${oauthTokens.accessToken}`;
+ if (storageAPI?.local) {
+ const result = await storageAPI.local.get(["cal_oauth_tokens"]);
+ const oauthTokens = result.cal_oauth_tokens
+ ? (JSON.parse(result.cal_oauth_tokens as string) as OAuthTokens)
+ : null;
+
+ if (oauthTokens?.accessToken) {
+ return `Bearer ${oauthTokens.accessToken}`;
+ }
}
if (IS_DEV_MODE && DEV_API_KEY) {
diff --git a/companion/extension/entrypoints/content.ts b/companion/extension/entrypoints/content.ts
index f6a23dbd0b..e47cc3b4e9 100644
--- a/companion/extension/entrypoints/content.ts
+++ b/companion/extension/entrypoints/content.ts
@@ -47,13 +47,14 @@ export default defineContentScript({
sidebarContainer.style.transform = "translateX(100%)";
sidebarContainer.style.display = "none";
- // Create iframe container with max width
+ // Create iframe container - positioned to right side
const iframeContainer = document.createElement("div");
- iframeContainer.style.width = "100%";
- iframeContainer.style.height = "100%";
- iframeContainer.style.display = "flex";
- iframeContainer.style.justifyContent = "flex-end";
- iframeContainer.style.pointerEvents = "none";
+ iframeContainer.style.position = "absolute";
+ iframeContainer.style.top = "0";
+ iframeContainer.style.right = "0";
+ iframeContainer.style.bottom = "0";
+ iframeContainer.style.width = "400px";
+ iframeContainer.style.overflow = "hidden";
const iframe = document.createElement("iframe");
// URL is determined at build time:
@@ -62,13 +63,18 @@ export default defineContentScript({
const COMPANION_URL =
(import.meta.env.EXPO_PUBLIC_COMPANION_DEV_URL as string) || "https://companion.cal.com";
iframe.src = COMPANION_URL;
- iframe.style.width = "400px";
- iframe.style.height = "100%";
- iframe.style.border = "none";
- iframe.style.borderRadius = "0";
- iframe.style.backgroundColor = "transparent";
- iframe.style.pointerEvents = "auto";
- iframe.style.transition = "none";
+ // Use explicit dimensions - Brave has issues with percentage-based sizing
+ iframe.style.cssText = `
+ position: absolute !important;
+ top: 0 !important;
+ left: 0 !important;
+ width: 400px !important;
+ height: 100vh !important;
+ border: none !important;
+ background-color: transparent !important;
+ pointer-events: auto !important;
+ display: block !important;
+ `;
iframeContainer.appendChild(iframe);
@@ -108,15 +114,17 @@ export default defineContentScript({
if (event.data.type === "cal-companion-expand") {
// Disable transition for instant expansion
iframe.style.transition = "none";
- iframe.style.width = "100%";
- iframeContainer.style.pointerEvents = "auto";
- iframeContainer.style.justifyContent = "center";
+ iframe.style.width = "100vw";
+ iframeContainer.style.width = "100%";
+ iframeContainer.style.left = "0";
+ iframeContainer.style.right = "0";
} else if (event.data.type === "cal-companion-collapse") {
// Disable transition for instant collapse
iframe.style.transition = "none";
iframe.style.width = "400px";
- iframeContainer.style.pointerEvents = "none";
- iframeContainer.style.justifyContent = "flex-end";
+ iframeContainer.style.width = "400px";
+ iframeContainer.style.left = "auto";
+ iframeContainer.style.right = "0";
} else if (event.data.type === "cal-extension-oauth-request") {
// Handle OAuth request from iframe
handleOAuthRequest(event.data.authUrl, iframe.contentWindow);
@@ -521,6 +529,7 @@ export default defineContentScript({
// Listen for extension icon click
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ // Skip debug-log messages to avoid infinite loop
if (message.action === "icon-clicked") {
if (isClosed) {
openSidebar();
diff --git a/companion/extension/env.d.ts b/companion/extension/env.d.ts
index 30d62bf239..7918435a14 100644
--- a/companion/extension/env.d.ts
+++ b/companion/extension/env.d.ts
@@ -3,8 +3,25 @@
interface ImportMetaEnv {
readonly EXPO_PUBLIC_CAL_API_KEY?: string;
readonly EXPO_PUBLIC_COMPANION_DEV_URL?: string;
+
+ // Default OAuth config (Chrome/Brave)
readonly EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID?: string;
readonly EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI?: string;
+
+ // Firefox-specific OAuth config
+ readonly EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_FIREFOX?: string;
+ readonly EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_FIREFOX?: string;
+
+ // Safari-specific OAuth config
+ readonly EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_SAFARI?: string;
+ readonly EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_SAFARI?: string;
+
+ // Edge-specific OAuth config
+ readonly EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_EDGE?: string;
+ readonly EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_EDGE?: string;
+
+ // Browser target set during build
+ readonly BROWSER_TARGET?: "chrome" | "firefox" | "safari" | "edge";
}
interface ImportMeta {
diff --git a/companion/package.json b/companion/package.json
index a6d0eac369..ae717510f4 100644
--- a/companion/package.json
+++ b/companion/package.json
@@ -1,22 +1,36 @@
{
"name": "cal-companion",
"displayName": "Cal.com Companion",
- "version": "1.7.3",
+ "version": "1.7.4",
"main": "index.js",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
+ "ext": "wxt",
"ext:build": "wxt build",
"ext:build-prod": "BUILD_FOR_STORE=true wxt build",
- "build:firefox": "wxt build -b firefox",
- "build:firefox-prod": "BUILD_FOR_STORE=true wxt build -b firefox",
- "build:safari": "wxt build -b safari",
- "build:safari-prod": "BUILD_FOR_STORE=true wxt build -b safari",
- "ext": "wxt",
"ext:zip": "wxt zip",
"ext:zip-prod": "BUILD_FOR_STORE=true wxt zip",
+ "ext:build-chrome": "BROWSER_TARGET=chrome wxt build -b chrome",
+ "ext:build-chrome-prod": "BUILD_FOR_STORE=true BROWSER_TARGET=chrome wxt build -b chrome",
+ "ext:zip-chrome": "BROWSER_TARGET=chrome wxt zip -b chrome",
+ "ext:zip-chrome-prod": "BUILD_FOR_STORE=true BROWSER_TARGET=chrome wxt zip -b chrome",
+ "ext:build-firefox": "BROWSER_TARGET=firefox wxt build -b firefox",
+ "ext:build-firefox-prod": "BUILD_FOR_STORE=true BROWSER_TARGET=firefox wxt build -b firefox",
+ "ext:zip-firefox": "BROWSER_TARGET=firefox wxt zip -b firefox",
+ "ext:zip-firefox-prod": "BUILD_FOR_STORE=true BROWSER_TARGET=firefox wxt zip -b firefox",
+ "ext:build-safari": "BROWSER_TARGET=safari wxt build -b safari",
+ "ext:build-safari-prod": "BUILD_FOR_STORE=true BROWSER_TARGET=safari wxt build -b safari",
+ "ext:zip-safari": "BROWSER_TARGET=safari wxt zip -b safari",
+ "ext:zip-safari-prod": "BUILD_FOR_STORE=true BROWSER_TARGET=safari wxt zip -b safari",
+ "ext:build-edge": "BROWSER_TARGET=edge wxt build -b edge",
+ "ext:build-edge-prod": "BUILD_FOR_STORE=true BROWSER_TARGET=edge wxt build -b edge",
+ "ext:zip-edge": "BROWSER_TARGET=edge wxt zip -b edge",
+ "ext:zip-edge-prod": "BUILD_FOR_STORE=true BROWSER_TARGET=edge wxt zip -b edge",
+ "ext:build-all": "npm run ext:build && npm run ext:build-firefox && npm run ext:build-safari && npm run ext:build-edge",
+ "ext:build-all-prod": "npm run ext:build-prod && npm run ext:build-firefox-prod && npm run ext:build-safari-prod && npm run ext:build-edge-prod",
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
"format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
"prepare": "cd .. && husky companion/.husky"
diff --git a/companion/services/oauthService.ts b/companion/services/oauthService.ts
index a89e308491..38c6b314ba 100644
--- a/companion/services/oauthService.ts
+++ b/companion/services/oauthService.ts
@@ -238,6 +238,7 @@ export class CalComOAuthService {
private async launchExtensionAuthFlow(authUrl: string): Promise {
return new Promise((resolve, reject) => {
+ // Try Chrome/Chromium identity API first
if (typeof chrome !== "undefined" && chrome.identity) {
chrome.identity.launchWebAuthFlow({ url: authUrl, interactive: true }, (responseUrl) => {
if (chrome.runtime.lastError) {
@@ -251,6 +252,29 @@ export class CalComOAuthService {
return;
}
+ // Try Firefox/Safari browser.identity API (Promise-based)
+ // @ts-ignore - Firefox/Safari use browser namespace
+ if (typeof browser !== "undefined" && browser.identity) {
+ try {
+ // @ts-ignore - Firefox/Safari browser.identity returns Promise
+ browser.identity
+ .launchWebAuthFlow({ url: authUrl, interactive: true })
+ .then((responseUrl: string | undefined) => {
+ if (responseUrl) {
+ resolve(responseUrl);
+ } else {
+ reject(new Error("OAuth cancelled"));
+ }
+ })
+ .catch((error: Error) => {
+ reject(new Error(`OAuth flow failed: ${error.message}`));
+ });
+ return;
+ } catch {
+ // Fall through to iframe-based flow
+ }
+ }
+
if (this.isRunningInIframe()) {
this.requestOAuthViaPostMessage(authUrl, resolve, reject);
return;
@@ -511,10 +535,108 @@ export class CalComOAuthService {
}
}
+/**
+ * Browser type for OAuth configuration selection.
+ * Used to determine which OAuth client credentials to use.
+ */
+type BrowserType = "chrome" | "firefox" | "safari" | "edge" | "brave" | "unknown";
+
+/**
+ * Detects the current browser type for OAuth configuration.
+ * This is used in web/extension context to select the appropriate OAuth credentials.
+ */
+function detectBrowserType(): BrowserType {
+ if (Platform.OS !== "web" || typeof navigator === "undefined") {
+ return "unknown";
+ }
+
+ const userAgent = navigator.userAgent.toLowerCase();
+
+ // Check for Brave first (it identifies as Chrome but has Brave-specific properties)
+ // @ts-ignore - Brave adds this to navigator
+ if (navigator.brave && typeof navigator.brave.isBrave === "function") {
+ return "brave";
+ }
+
+ // Check for Edge (Chromium-based Edge includes "Edg/" in user agent)
+ if (userAgent.includes("edg/")) {
+ return "edge";
+ }
+
+ // Check for Firefox
+ if (userAgent.includes("firefox")) {
+ return "firefox";
+ }
+
+ // Check for Safari (must check after Chrome since Chrome also includes "safari")
+ if (
+ userAgent.includes("safari") &&
+ !userAgent.includes("chrome") &&
+ !userAgent.includes("chromium")
+ ) {
+ return "safari";
+ }
+
+ // Check for Chrome (or other Chromium-based browsers)
+ if (userAgent.includes("chrome") || userAgent.includes("chromium")) {
+ return "chrome";
+ }
+
+ return "unknown";
+}
+
+/**
+ * Gets browser-specific OAuth configuration.
+ * Falls back to default (Chrome) config if browser-specific config is not available.
+ */
+function getBrowserSpecificOAuthConfig(): { clientId: string; redirectUri: string } {
+ // Default values (Chrome/Brave)
+ const defaultClientId = process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID || "";
+ const defaultRedirectUri = process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI || "";
+
+ // For mobile apps, always use default config
+ if (Platform.OS !== "web") {
+ return { clientId: defaultClientId, redirectUri: defaultRedirectUri };
+ }
+
+ const browserType = detectBrowserType();
+
+ switch (browserType) {
+ case "firefox":
+ return {
+ clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_FIREFOX || defaultClientId,
+ redirectUri:
+ process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_FIREFOX || defaultRedirectUri,
+ };
+
+ case "safari":
+ return {
+ clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_SAFARI || defaultClientId,
+ redirectUri: process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_SAFARI || defaultRedirectUri,
+ };
+
+ case "edge":
+ return {
+ clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_EDGE || defaultClientId,
+ redirectUri: process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_EDGE || defaultRedirectUri,
+ };
+
+ case "chrome":
+ case "brave":
+ case "unknown":
+ default:
+ // Chrome, Brave, and unknown browsers use the default configuration
+ return { clientId: defaultClientId, redirectUri: defaultRedirectUri };
+ }
+}
+
export function createCalComOAuthService(overrides: Partial = {}): CalComOAuthService {
+ // Get browser-specific OAuth config
+ const browserConfig = getBrowserSpecificOAuthConfig();
+
const config: OAuthConfig = {
- clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID || "",
- redirectUri: process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI || "",
+ clientId: browserConfig.clientId,
+ redirectUri: browserConfig.redirectUri,
calcomBaseUrl: "https://app.cal.com",
...overrides,
};
diff --git a/companion/wxt.config.ts b/companion/wxt.config.ts
index 9239c68fb5..2c3a6d3384 100644
--- a/companion/wxt.config.ts
+++ b/companion/wxt.config.ts
@@ -4,6 +4,44 @@ import { defineConfig } from "wxt";
// Forces production URL (https://companion.cal.com) and excludes localhost permissions
const isBuildForStore = process.env.BUILD_FOR_STORE === "true";
+// BROWSER_TARGET is set during build to determine which OAuth credentials to use
+// Values: "chrome" (default), "firefox", "safari", "edge"
+const browserTarget = process.env.BROWSER_TARGET || "chrome";
+
+/**
+ * Get browser-specific OAuth configuration.
+ * Falls back to default (Chrome) config if browser-specific config is not set.
+ */
+function getOAuthConfig() {
+ const defaultClientId = process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID || "";
+ const defaultRedirectUri = process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI || "";
+
+ switch (browserTarget) {
+ case "firefox":
+ return {
+ clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_FIREFOX || defaultClientId,
+ redirectUri:
+ process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_FIREFOX || defaultRedirectUri,
+ };
+ case "safari":
+ return {
+ clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_SAFARI || defaultClientId,
+ redirectUri: process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_SAFARI || defaultRedirectUri,
+ };
+ case "edge":
+ return {
+ clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_EDGE || defaultClientId,
+ redirectUri: process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_EDGE || defaultRedirectUri,
+ };
+ case "chrome":
+ default:
+ return {
+ clientId: defaultClientId,
+ redirectUri: defaultRedirectUri,
+ };
+ }
+}
+
export default defineConfig({
hooks: {
"build:manifestGenerated": (_wxt, manifest) => {
@@ -20,7 +58,7 @@ export default defineConfig({
outDir: ".output",
manifest: {
name: "Cal.com Companion",
- version: "1.7.3",
+ version: "1.7.4",
description: "Your calendar companion for quick booking and scheduling",
permissions: ["activeTab", "storage", "identity"],
host_permissions: [
@@ -55,12 +93,16 @@ export default defineConfig({
const devUrl = isBuildForStore ? "" : process.env.EXPO_PUBLIC_COMPANION_DEV_URL || "";
const isLocalDev = Boolean(devUrl && devUrl.includes("localhost"));
+ // Get OAuth config for the target browser
+ const oauthConfig = getOAuthConfig();
+
// Log build mode for clarity
if (isBuildForStore) {
console.log("\nšŖ STORE BUILD: Using https://companion.cal.com\n");
} else if (isLocalDev) {
console.log(`\nš§ DEV BUILD: Using ${devUrl}\n`);
}
+ console.log(`š Browser Target: ${browserTarget}\n`);
return {
resolve: {
@@ -71,12 +113,44 @@ export default defineConfig({
define: {
global: "globalThis",
__DEV__: JSON.stringify(false),
+
+ // Browser target for runtime detection
+ "import.meta.env.BROWSER_TARGET": JSON.stringify(browserTarget),
+
+ // Default OAuth config (Chrome/Brave) - always available for fallback
"import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID": JSON.stringify(
process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID
),
"import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI": JSON.stringify(
process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI
),
+
+ // Browser-specific OAuth config (resolved based on BROWSER_TARGET)
+ "import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_FIREFOX": JSON.stringify(
+ process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_FIREFOX
+ ),
+ "import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_FIREFOX": JSON.stringify(
+ process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_FIREFOX
+ ),
+ "import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_SAFARI": JSON.stringify(
+ process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_SAFARI
+ ),
+ "import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_SAFARI": JSON.stringify(
+ process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_SAFARI
+ ),
+ "import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_EDGE": JSON.stringify(
+ process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_EDGE
+ ),
+ "import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_EDGE": JSON.stringify(
+ process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_EDGE
+ ),
+
+ // Pre-resolved OAuth config for the build target (for convenience)
+ "process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID": JSON.stringify(oauthConfig.clientId),
+ "process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI": JSON.stringify(
+ oauthConfig.redirectUri
+ ),
+
// Use devUrl which respects BUILD_FOR_STORE flag
"import.meta.env.EXPO_PUBLIC_COMPANION_DEV_URL": JSON.stringify(devUrl),
"import.meta.env.EXPO_PUBLIC_CACHE_STALE_TIME_MINUTES": JSON.stringify(