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 && (<Component />)}` patterns with explicit
`{condition ? (<Component />) : null}` syntax across the companion app.

This fixes "Unexpected text node: . A text node cannot be a child of
a <View>" 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
This commit is contained in:
Dhairyashil Shinde
2025-12-19 23:50:20 +05:30
committed by GitHub
parent af4a10c008
commit 69a857ad98
25 changed files with 854 additions and 411 deletions
+32
View File
@@ -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://<id>.chromiumapp.org/oauth/callback | Get ID from `chrome://extensions` |
# | Brave | https://<id>.chromiumapp.org/oauth/callback | Same extension package as Chrome |
# | Edge | https://<id>.chromiumapp.org/oauth/callback | Different ID from Chrome |
# | Firefox | https://<uuid>.extensions.allizom.org/oauth/callback | UUID from `browser.identity.getRedirectURL()` |
# | Safari | https://<team>.<bundle>.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)
# ===========================================
+8 -8
View File
@@ -690,17 +690,17 @@ export default function EventTypes() {
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
>
{selectedEventType && (
{selectedEventType ? (
<>
<View className="border-b border-gray-200 p-6">
<Text className="mb-2 text-lg font-semibold text-gray-900">
{selectedEventType.title}
</Text>
{selectedEventType.description && (
{selectedEventType.description ? (
<Text className="text-sm text-gray-600">
{normalizeMarkdown(selectedEventType.description)}
</Text>
)}
) : null}
</View>
<View className="p-2">
@@ -759,7 +759,7 @@ export default function EventTypes() {
</TouchableOpacity>
</View>
</>
)}
) : null}
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
@@ -844,12 +844,12 @@ export default function EventTypes() {
Delete Event Type
</Text>
<Text className="text-sm leading-5 text-gray-600">
{eventTypeToDelete && (
{eventTypeToDelete ? (
<>
This will permanently delete the "{eventTypeToDelete.title}" event type.
This action cannot be undone.
</>
)}
) : null}
</Text>
</View>
</View>
@@ -881,13 +881,13 @@ export default function EventTypes() {
</FullScreenModal>
{/* Toast for Web Platform */}
{showToast && (
{showToast ? (
<View className="absolute bottom-8 left-1/2 z-50 -translate-x-1/2 transform">
<View className="rounded-full bg-gray-800 px-6 py-3 shadow-lg">
<Text className="text-sm font-medium text-white">{toastMessage}</Text>
</View>
</View>
)}
) : null}
</>
);
}
+12 -12
View File
@@ -273,11 +273,11 @@ export default function Availability() {
<View className="mr-4 flex-1">
<View className="mb-1 flex-row flex-wrap items-center">
<Text className="text-base font-semibold text-[#333]">{schedule.name}</Text>
{schedule.isDefault && (
{schedule.isDefault ? (
<View className="ml-2 rounded bg-[#666] px-2 py-0.5">
<Text className="text-xs font-semibold text-white">Default</Text>
</View>
)}
) : null}
</View>
{schedule.availability && schedule.availability.length > 0 ? (
@@ -360,7 +360,7 @@ export default function Availability() {
<Header />
{/* Empty state - no schedules */}
{showEmptyState && (
{showEmptyState ? (
<View className="flex-1 bg-gray-50" style={{ paddingBottom: 100 }}>
<ScrollView
contentContainerStyle={{
@@ -380,10 +380,10 @@ export default function Availability() {
/>
</ScrollView>
</View>
)}
) : null}
{/* Search bar and content for non-empty states */}
{!showEmptyState && (
{!showEmptyState ? (
<>
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
<TextInput
@@ -406,7 +406,7 @@ export default function Availability() {
</View>
{/* Search empty state */}
{showSearchEmptyState && (
{showSearchEmptyState ? (
<View className="flex-1 bg-gray-50" style={{ paddingBottom: 100 }}>
<ScrollView
contentContainerStyle={{
@@ -424,10 +424,10 @@ export default function Availability() {
/>
</ScrollView>
</View>
)}
) : null}
{/* Schedules list */}
{showList && (
{showList ? (
<View className="flex-1 px-2 pt-4 md:px-4">
<View className="flex-1 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
<FlatList
@@ -440,9 +440,9 @@ export default function Availability() {
/>
</View>
</View>
)}
) : null}
</>
)}
) : null}
{/* Create Schedule Modal */}
<FullScreenModal
@@ -536,7 +536,7 @@ export default function Availability() {
{/* Actions List */}
<View className="p-2">
{/* Set as Default - only show if not already default */}
{selectedSchedule && !selectedSchedule.isDefault && (
{selectedSchedule && !selectedSchedule.isDefault ? (
<>
<TouchableOpacity
onPress={() => {
@@ -553,7 +553,7 @@ export default function Availability() {
<View className="mx-4 my-2 h-px bg-gray-200" />
</>
)}
) : null}
{/* Duplicate */}
<TouchableOpacity
+93 -114
View File
@@ -429,7 +429,7 @@ export default function Bookings() {
/>
</View>
</View>
{selectedEventTypeId !== null && (
{selectedEventTypeId !== null ? (
<View className="mt-2 flex-row items-center justify-between rounded-lg border border-gray-200 bg-white px-3 py-2">
<Text className="flex-1 text-sm text-[#333]">
Filtered by {selectedEventTypeLabel || "event type"}
@@ -438,7 +438,7 @@ export default function Bookings() {
<Text className="text-sm font-semibold text-[#007AFF]">Clear filter</Text>
</TouchableOpacity>
</View>
)}
) : null}
</View>
</>
);
@@ -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 (
<View className="border-b border-[#E5E5EA] bg-white">
<TouchableOpacity
@@ -953,16 +993,14 @@ export default function Bookings() {
{formatTime(startTime)} - {formatTime(endTime)}
</Text>
</View>
{/* Badges Row */}
<View className="mb-3 flex-row flex-wrap items-center">
{isPending && (
{isPending ? (
<View className="mb-1 mr-2 rounded bg-[#FF9500] px-2 py-0.5">
<Text className="text-xs font-medium text-white">Unconfirmed</Text>
</View>
)}
) : null}
</View>
{/* Title */}
<Text
className={`mb-2 text-lg font-medium leading-5 text-[#333] ${isCancelled || isRejected ? "line-through" : ""}`}
@@ -970,109 +1008,52 @@ export default function Bookings() {
>
{item.title}
</Text>
{/* Description */}
{item.description && (
{item.description ? (
<Text className="mb-2 text-sm leading-5 text-[#666]" numberOfLines={1}>
&quot;{item.description}&quot;
</Text>
)}
) : null}
{/* Host and Attendees */}
{((item.hosts && item.hosts.length > 0) ||
item.user ||
(item.attendees && item.attendees.length > 0)) && (
<Text className="mb-2 text-sm text-[#333]">
{(() => {
// 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;
})()}
</Text>
)}
{/* Meeting Link - only for video conferencing apps */}
{(() => {
const meetingInfo = getMeetingInfo(item.location);
if (!meetingInfo) return null;
return (
<View className="mb-1 flex-row">
<TouchableOpacity
className="flex-row items-center"
style={{ alignSelf: "flex-start" }}
hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }}
onPress={async (e) => {
e.stopPropagation();
try {
await Linking.openURL(meetingInfo.cleanUrl);
} catch {
showErrorAlert("Error", "Failed to open meeting link. Please try again.");
}
}}
>
{meetingInfo.iconUrl ? (
<SvgImage
uri={meetingInfo.iconUrl}
width={16}
height={16}
style={{ marginRight: 6 }}
/>
) : (
<Ionicons
name="videocam"
size={16}
color="#007AFF"
style={{ marginRight: 6 }}
/>
)}
<Text className="text-sm font-medium text-[#007AFF]">{meetingInfo.label}</Text>
</TouchableOpacity>
</View>
);
})()}
{hostAndAttendeesDisplay ? (
<Text className="mb-2 text-sm text-[#333]">{hostAndAttendeesDisplay}</Text>
) : null}
{/* Meeting Link */}
{meetingInfo ? (
<View className="mb-1 flex-row">
<TouchableOpacity
className="flex-row items-center"
style={{ alignSelf: "flex-start" }}
hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }}
onPress={async (e) => {
e.stopPropagation();
try {
await Linking.openURL(meetingInfo.cleanUrl);
} catch {
showErrorAlert("Error", "Failed to open meeting link. Please try again.");
}
}}
>
{meetingInfo.iconUrl ? (
<SvgImage
uri={meetingInfo.iconUrl}
width={16}
height={16}
style={{ marginRight: 6 }}
/>
) : (
<Ionicons name="videocam" size={16} color="#007AFF" style={{ marginRight: 6 }} />
)}
<Text className="text-sm font-medium text-[#007AFF]">{meetingInfo.label}</Text>
</TouchableOpacity>
</View>
) : null}
</TouchableOpacity>
{/* Action buttons row */}
<View
className="flex-row items-center justify-end"
style={{ paddingHorizontal: 16, paddingBottom: 16, gap: 8 }}
>
{/* Confirm and Reject buttons for unconfirmed bookings */}
{isPending && (
{isPending ? (
<>
<TouchableOpacity
className="flex-row items-center justify-center rounded-lg border border-[#E5E5EA] bg-white"
@@ -1119,9 +1100,7 @@ export default function Bookings() {
<Text className="ml-1 text-sm font-medium text-[#3C3F44]">Reject</Text>
</TouchableOpacity>
</>
)}
{/* Three dots button */}
) : null}
<TouchableOpacity
className="items-center justify-center rounded-lg border border-[#E5E5EA]"
style={{ width: 32, height: 32 }}
@@ -1193,7 +1172,7 @@ export default function Bookings() {
{renderSegmentedControl()}
{/* Empty state - no bookings */}
{showEmptyState && (
{showEmptyState ? (
<View className="flex-1 bg-gray-50" style={{ paddingBottom: 100 }}>
<ScrollView
contentContainerStyle={{
@@ -1211,10 +1190,10 @@ export default function Bookings() {
/>
</ScrollView>
</View>
)}
) : null}
{/* Search empty state */}
{showSearchEmptyState && (
{showSearchEmptyState ? (
<View className="flex-1 bg-gray-50" style={{ paddingBottom: 100 }}>
<ScrollView
contentContainerStyle={{
@@ -1232,10 +1211,10 @@ export default function Bookings() {
/>
</ScrollView>
</View>
)}
) : null}
{/* Bookings list */}
{showList && (
{showList ? (
<View className="flex-1 px-2 pt-4 md:px-4">
<View className="flex-1 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
<FlatList
@@ -1248,7 +1227,7 @@ export default function Bookings() {
/>
</View>
</View>
)}
) : null}
{/* Filter Modal */}
<FullScreenModal
@@ -1290,17 +1269,17 @@ export default function Bookings() {
>
{eventType.title}
</Text>
{selectedEventTypeId === eventType.id && (
{selectedEventTypeId === eventType.id ? (
<Ionicons name="checkmark" size={20} color="#000" />
)}
) : null}
</TouchableOpacity>
))}
{eventTypes.length === 0 && (
{eventTypes.length === 0 ? (
<View className="items-center py-4">
<Text className="text-sm text-[#666]">No event types found</Text>
</View>
)}
) : null}
</ScrollView>
)}
</TouchableOpacity>
@@ -1364,7 +1343,7 @@ export default function Bookings() {
}}
>
<ScrollView className="flex-1 p-4">
{rescheduleBooking && (
{rescheduleBooking ? (
<>
<Text className="mb-4 text-base text-gray-600">
Reschedule "{rescheduleBooking.title}"
@@ -1442,7 +1421,7 @@ export default function Bookings() {
<Text className="text-center text-base font-medium text-gray-700">Cancel</Text>
</TouchableOpacity>
</>
)}
) : null}
</ScrollView>
</FullScreenModal>
+10 -10
View File
@@ -537,7 +537,7 @@ export default function AvailabilityDetail() {
>
{DAY_ABBREVIATIONS[dayIndex]}
</Text>
{isEnabled && firstSlot && (
{isEnabled && firstSlot ? (
<View className="flex-row items-center gap-2">
<TouchableOpacity
onPress={() =>
@@ -563,16 +563,16 @@ export default function AvailabilityDetail() {
</Text>
</TouchableOpacity>
</View>
)}
) : null}
</View>
{isEnabled && (
{isEnabled ? (
<TouchableOpacity onPress={() => addTimeSlot(dayIndex)} className="p-1">
<Ionicons name="add-circle-outline" size={20} color="#007AFF" />
</TouchableOpacity>
)}
) : null}
</View>
{isEnabled && daySlots.length > 1 && (
{isEnabled && daySlots.length > 1 ? (
<View className="mt-2" style={{ marginLeft: 108 }}>
{daySlots.slice(1).map((slot, slotIndex) => (
<View key={slotIndex + 1} className="mb-2 flex-row items-center gap-2">
@@ -616,7 +616,7 @@ export default function AvailabilityDetail() {
</View>
))}
</View>
)}
) : null}
</View>
);
})}
@@ -641,7 +641,7 @@ export default function AvailabilityDetail() {
Add dates when your availability changes from your daily hours.
</Text>
{overrides.length > 0 && (
{overrides.length > 0 ? (
<View className="mb-4">
{overrides.map((override, index) => (
<View
@@ -675,7 +675,7 @@ export default function AvailabilityDetail() {
</View>
))}
</View>
)}
) : null}
<TouchableOpacity
onPress={handleAddOverride}
@@ -894,7 +894,7 @@ export default function AvailabilityDetail() {
</View>
{/* Time Picker (only if not unavailable) */}
{!isUnavailable && (
{!isUnavailable ? (
<View>
<Text className="mb-3 text-base font-semibold text-[#333]">
Which hours are you free?
@@ -919,7 +919,7 @@ export default function AvailabilityDetail() {
</TouchableOpacity>
</View>
</View>
)}
) : null}
{/* Save Button */}
<TouchableOpacity
+18 -18
View File
@@ -365,7 +365,7 @@ export default function BookingDetail() {
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="mb-4 text-base font-medium text-[#666]">Who</Text>
{/* Show host from user field or hosts array */}
{(booking.user || (booking.hosts && booking.hosts.length > 0)) && (
{booking.user || (booking.hosts && booking.hosts.length > 0) ? (
<View className="mb-4">
{booking.user ? (
<View className="flex-row items-start">
@@ -412,8 +412,8 @@ export default function BookingDetail() {
))
) : null}
</View>
)}
{booking.attendees && booking.attendees.length > 0 && (
) : null}
{booking.attendees && booking.attendees.length > 0 ? (
<View>
{booking.attendees.map((attendee, index) => (
<View key={index} className={`flex-row items-start ${index > 0 ? "mt-4" : ""}`}>
@@ -431,11 +431,11 @@ export default function BookingDetail() {
</View>
))}
</View>
)}
) : null}
</View>
{/* Where Section */}
{locationProvider && (
{locationProvider ? (
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="mb-4 text-base font-medium text-[#666]">Where</Text>
{locationProvider.url ? (
@@ -443,14 +443,14 @@ export default function BookingDetail() {
onPress={handleJoinMeeting}
className="flex-row flex-wrap items-center"
>
{locationProvider.iconUrl && (
{locationProvider.iconUrl ? (
<SvgImage
uri={locationProvider.iconUrl}
width={20}
height={20}
style={{ marginRight: 8 }}
/>
)}
) : null}
<Text className="text-base text-[#007AFF]">{locationProvider.label}: </Text>
<Text className="flex-1 text-base text-[#007AFF]" numberOfLines={1}>
{locationProvider.url}
@@ -458,55 +458,55 @@ export default function BookingDetail() {
</TouchableOpacity>
) : (
<View className="flex-row items-center">
{locationProvider.iconUrl && (
{locationProvider.iconUrl ? (
<SvgImage
uri={locationProvider.iconUrl}
width={20}
height={20}
style={{ marginRight: 8 }}
/>
)}
) : null}
<Text className="text-base text-[#333]">{locationProvider.label}</Text>
</View>
)}
</View>
)}
) : null}
{/* Recurring Event Section */}
{(booking.recurringEventId || booking.recurringBookingUid) && (
{booking.recurringEventId || booking.recurringBookingUid ? (
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="mb-2 text-base font-medium text-[#666]">Recurring Event</Text>
<Text className="text-base text-[#666]">Every 2 weeks for 6 occurrences</Text>
</View>
)}
) : null}
{/* Description Section */}
{booking.description && (
{booking.description ? (
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="mb-2 text-base font-medium text-[#666]">Description</Text>
<Text className="text-base leading-6 text-[#666]">{booking.description}</Text>
</View>
)}
) : null}
{/* Join Meeting Button */}
{locationProvider?.url && (
{locationProvider?.url ? (
<TouchableOpacity
onPress={handleJoinMeeting}
className="mb-2 flex-row items-center justify-center rounded-lg bg-black px-6 py-4"
>
{locationProvider.iconUrl && (
{locationProvider.iconUrl ? (
<SvgImage
uri={locationProvider.iconUrl}
width={20}
height={20}
style={{ marginRight: 8 }}
/>
)}
) : null}
<Text className="text-base font-semibold text-white">
Join {locationProvider.label}
</Text>
</TouchableOpacity>
)}
) : null}
</ScrollView>
</View>
+33 -33
View File
@@ -1157,7 +1157,7 @@ export default function EventTypeDetail() {
}}
contentContainerStyle={{ padding: 20, paddingBottom: 200 }}
>
{activeTab === "basics" && (
{activeTab === "basics" ? (
<BasicsTab
eventTitle={eventTitle}
setEventTitle={setEventTitle}
@@ -1182,7 +1182,7 @@ export default function EventTypeDetail() {
locationOptions={getLocationOptionsForDropdown()}
conferencingLoading={conferencingLoading}
/>
)}
) : null}
{/* Duration Multi-Select Modal */}
<Modal
@@ -1213,9 +1213,9 @@ export default function EventTypeDetail() {
>
{duration}
</Text>
{selectedDurations.includes(duration) && (
{selectedDurations.includes(duration) ? (
<Ionicons name="checkmark" size={20} color="#000" />
)}
) : null}
</TouchableOpacity>
))}
</ScrollView>
@@ -1260,9 +1260,9 @@ export default function EventTypeDetail() {
>
{duration}
</Text>
{defaultDuration === duration && (
{defaultDuration === duration ? (
<Ionicons name="checkmark" size={20} color="#000" />
)}
) : null}
</TouchableOpacity>
))}
</View>
@@ -1302,15 +1302,15 @@ export default function EventTypeDetail() {
>
{schedule.name}
</Text>
{schedule.isDefault && (
{schedule.isDefault ? (
<Text className="rounded bg-[#E8F5E8] px-1.5 py-0.5 text-xs font-medium text-[#34C759]">
Default
</Text>
)}
) : null}
</View>
{selectedSchedule?.id === schedule.id && (
{selectedSchedule?.id === schedule.id ? (
<Ionicons name="checkmark" size={20} color="#000" />
)}
) : null}
</TouchableOpacity>
))}
</View>
@@ -1368,10 +1368,10 @@ export default function EventTypeDetail() {
>
{tz}
</Text>
{(selectedTimezone === tz ||
(selectedScheduleDetails?.timeZone === tz && !selectedTimezone)) && (
{selectedTimezone === tz ||
(selectedScheduleDetails?.timeZone === tz && !selectedTimezone) ? (
<Ionicons name="checkmark" size={20} color="#000" />
)}
) : null}
</TouchableOpacity>
))}
</ScrollView>
@@ -1410,9 +1410,9 @@ export default function EventTypeDetail() {
>
{option}
</Text>
{beforeEventBuffer === option && (
{beforeEventBuffer === option ? (
<Ionicons name="checkmark" size={20} color="#000" />
)}
) : null}
</TouchableOpacity>
))}
</View>
@@ -1450,9 +1450,9 @@ export default function EventTypeDetail() {
>
{option}
</Text>
{afterEventBuffer === option && (
{afterEventBuffer === option ? (
<Ionicons name="checkmark" size={20} color="#000" />
)}
) : null}
</TouchableOpacity>
))}
</View>
@@ -1490,9 +1490,9 @@ export default function EventTypeDetail() {
>
{option}
</Text>
{minimumNoticeUnit === option && (
{minimumNoticeUnit === option ? (
<Ionicons name="checkmark" size={20} color="#000" />
)}
) : null}
</TouchableOpacity>
))}
</View>
@@ -1622,9 +1622,9 @@ export default function EventTypeDetail() {
>
{option}
</Text>
{slotInterval === option && (
{slotInterval === option ? (
<Ionicons name="checkmark" size={20} color="#000" />
)}
) : null}
</TouchableOpacity>
))}
</View>
@@ -1662,16 +1662,16 @@ export default function EventTypeDetail() {
>
{option === "weekly" ? "week" : option === "monthly" ? "month" : "year"}
</Text>
{recurringFrequency === option && (
{recurringFrequency === option ? (
<Ionicons name="checkmark" size={20} color="#000" />
)}
) : null}
</TouchableOpacity>
))}
</View>
</TouchableOpacity>
</Modal>
{activeTab === "availability" && (
{activeTab === "availability" ? (
<AvailabilityTab
selectedSchedule={selectedSchedule}
setShowScheduleDropdown={setShowScheduleDropdown}
@@ -1682,9 +1682,9 @@ export default function EventTypeDetail() {
formatTime={formatTime}
selectedTimezone={selectedTimezone}
/>
)}
) : null}
{activeTab === "limits" && (
{activeTab === "limits" ? (
<LimitsTab
beforeEventBuffer={beforeEventBuffer}
setShowBeforeBufferDropdown={setShowBeforeBufferDropdown}
@@ -1733,9 +1733,9 @@ export default function EventTypeDetail() {
rangeEndDate={rangeEndDate}
setRangeEndDate={setRangeEndDate}
/>
)}
) : null}
{activeTab === "advanced" && (
{activeTab === "advanced" ? (
<AdvancedTab
requiresConfirmation={requiresConfirmation}
setRequiresConfirmation={setRequiresConfirmation}
@@ -1779,9 +1779,9 @@ export default function EventTypeDetail() {
// Event type ID for private links
eventTypeId={id}
/>
)}
) : null}
{activeTab === "recurring" && (
{activeTab === "recurring" ? (
<RecurringTab
recurringEnabled={recurringEnabled}
setRecurringEnabled={setRecurringEnabled}
@@ -1793,9 +1793,9 @@ export default function EventTypeDetail() {
setRecurringOccurrences={setRecurringOccurrences}
setShowFrequencyDropdown={setShowRecurringFrequencyDropdown}
/>
)}
) : null}
{activeTab === "other" && (
{activeTab === "other" ? (
<View className="rounded-2xl bg-white p-5 shadow-md">
<Text className="mb-2 text-lg font-semibold text-[#333]">Additional Settings</Text>
<Text className="mb-5 text-sm leading-5 text-[#666]">
@@ -1884,7 +1884,7 @@ export default function EventTypeDetail() {
</TouchableOpacity>
</View>
</View>
)}
) : null}
</ScrollView>
{/* Bottom Action Bar */}
+4 -4
View File
@@ -163,7 +163,7 @@ export function BookingActionsModal({
</View>
{/* View Recordings */}
{hasLocationUrl && (
{hasLocationUrl ? (
<TouchableOpacity
onPress={() => {
if (afterEventActionsDisabled) return;
@@ -184,10 +184,10 @@ export function BookingActionsModal({
View Recordings
</Text>
</TouchableOpacity>
)}
) : null}
{/* Meeting Session Details */}
{hasLocationUrl && (
{hasLocationUrl ? (
<TouchableOpacity
onPress={() => {
if (afterEventActionsDisabled) return;
@@ -208,7 +208,7 @@ export function BookingActionsModal({
Meeting Session Details
</Text>
</TouchableOpacity>
)}
) : null}
{/* Mark as No-Show - disabled for upcoming and unconfirmed bookings */}
<TouchableOpacity
+2 -2
View File
@@ -36,7 +36,7 @@ export function EmptyScreen({
{description}
</Text>
{buttonText && onButtonPress && (
{buttonText && onButtonPress ? (
<TouchableOpacity
className="flex-row items-center justify-center gap-1 rounded-lg bg-gray-900 px-4 py-2.5"
onPress={onButtonPress}
@@ -44,7 +44,7 @@ export function EmptyScreen({
<Ionicons name="add" size={18} color="#fff" />
<Text className="text-base font-medium text-white">{buttonText}</Text>
</TouchableOpacity>
)}
) : null}
</View>
</View>
);
+2 -2
View File
@@ -209,9 +209,9 @@ export function Header() {
<Text className="text-lg font-semibold text-gray-900">
{userProfile?.name || "User"}
</Text>
{userProfile?.email && (
{userProfile?.email ? (
<Text className="text-sm text-gray-600">{userProfile.email}</Text>
)}
) : null}
</View>
</View>
</View>
+2 -2
View File
@@ -170,7 +170,7 @@ export const LocationsList: React.FC<LocationsListProps> = ({
{location.displayName}
</Text>
</View>
{!disabled && (
{!disabled ? (
<TouchableOpacity
onPress={() => onRemove(location.id)}
className="ml-2 p-1"
@@ -178,7 +178,7 @@ export const LocationsList: React.FC<LocationsListProps> = ({
>
<Ionicons name="close-circle" size={20} color="#9CA3AF" />
</TouchableOpacity>
)}
) : null}
</View>
{renderLocationInput(location)}
</View>
+2 -2
View File
@@ -39,7 +39,7 @@ export function Tooltip({ text, children }: TooltipProps) {
style={{ position: "relative" }}
>
{children}
{showTooltip && (
{showTooltip ? (
<View
// @ts-ignore - web-only styles
style={{
@@ -63,7 +63,7 @@ export function Tooltip({ text, children }: TooltipProps) {
{text}
</Text>
</View>
)}
) : null}
</View>
);
}
@@ -291,7 +291,7 @@ export function AdvancedTab(props: AdvancedTabProps) {
</View>
{/* Seats Configuration - shown when enabled */}
{props.seatsEnabled && (
{props.seatsEnabled ? (
<View className="gap-4 border-t border-[#E5E5EA] p-5">
{/* Number of seats per booking */}
<View>
@@ -342,14 +342,14 @@ export function AdvancedTab(props: AdvancedTabProps) {
: "border-[#D1D5DB] bg-white"
}`}
>
{props.showAvailabilityCount && (
{props.showAvailabilityCount ? (
<Ionicons name="checkmark" size={14} color="#fff" />
)}
) : null}
</View>
<Text className="text-sm text-[#333]">Show the number of available seats</Text>
</TouchableOpacity>
</View>
)}
) : null}
</View>
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
@@ -397,7 +397,7 @@ export function AdvancedTab(props: AdvancedTabProps) {
</View>
{/* Timezone selector - shown when enabled */}
{props.lockTimezone && (
{props.lockTimezone ? (
<View className="border-t border-[#E5E5EA] p-5">
<Text className="mb-2 text-sm font-medium text-[#333]">Timezone</Text>
<TouchableOpacity
@@ -423,7 +423,7 @@ export function AdvancedTab(props: AdvancedTabProps) {
<Ionicons name="chevron-down" size={20} color="#666" />
</TouchableOpacity>
</View>
)}
) : null}
</View>
<ConfigureOnWebCard
@@ -42,7 +42,7 @@ export function AvailabilityTab(props: AvailabilityTabProps) {
</TouchableOpacity>
</View>
{props.selectedSchedule && (
{props.selectedSchedule ? (
<>
<View
className="mt-5 pt-5"
@@ -104,7 +104,7 @@ export function AvailabilityTab(props: AvailabilityTabProps) {
</View>
</View>
</>
)}
) : null}
</View>
);
}
@@ -84,7 +84,7 @@ export function BasicsTab(props: BasicsTabProps) {
{/* Duration Card */}
<View className="rounded-2xl bg-white p-5">
{!props.allowMultipleDurations && (
{!props.allowMultipleDurations ? (
<View className="mb-3">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Duration</Text>
<View className="flex-row items-center gap-3">
@@ -99,9 +99,9 @@ export function BasicsTab(props: BasicsTabProps) {
<Text className="text-base text-[#666]">Minutes</Text>
</View>
</View>
)}
) : null}
{props.allowMultipleDurations && (
{props.allowMultipleDurations ? (
<>
<View className="mb-3">
<Text className="mb-1.5 text-base font-semibold text-[#333]">
@@ -122,7 +122,7 @@ export function BasicsTab(props: BasicsTabProps) {
</TouchableOpacity>
</View>
{props.selectedDurations.length > 0 && (
{props.selectedDurations.length > 0 ? (
<View className="mb-3">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Default duration</Text>
<TouchableOpacity
@@ -135,9 +135,9 @@ export function BasicsTab(props: BasicsTabProps) {
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
</TouchableOpacity>
</View>
)}
) : null}
</>
)}
) : null}
<View className="flex-row items-start justify-between">
<Text className="mb-1 text-base font-medium text-[#333]">Allow multiple durations</Text>
@@ -183,7 +183,7 @@ export function LimitsTab(props: LimitsTabProps) {
},
]}
>
{props.limitBookingFrequency && (
{props.limitBookingFrequency ? (
<>
{props.frequencyLimits.map((limit) => (
<View key={limit.id} className="mt-4 flex-row items-center gap-3">
@@ -208,14 +208,14 @@ export function LimitsTab(props: LimitsTabProps) {
<Text className="text-base text-black">{limit.unit}</Text>
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
</TouchableOpacity>
{props.frequencyLimits.length > 1 && (
{props.frequencyLimits.length > 1 ? (
<TouchableOpacity
className="h-10 w-10 items-center justify-center rounded-lg border border-[#FFCCC7] bg-[#FFF1F0]"
onPress={() => props.removeFrequencyLimit(limit.id)}
>
<Ionicons name="trash-outline" size={20} color="#800000" />
</TouchableOpacity>
)}
) : null}
</View>
))}
<TouchableOpacity
@@ -226,7 +226,7 @@ export function LimitsTab(props: LimitsTabProps) {
<Text className="text-base font-medium text-black">Add Limit</Text>
</TouchableOpacity>
</>
)}
) : null}
</Animated.View>
</View>
@@ -282,7 +282,7 @@ export function LimitsTab(props: LimitsTabProps) {
},
]}
>
{props.limitTotalDuration && (
{props.limitTotalDuration ? (
<>
{props.durationLimits.map((limit) => (
<View key={limit.id} className="mt-4 flex-row items-center gap-3">
@@ -310,14 +310,14 @@ export function LimitsTab(props: LimitsTabProps) {
<Text className="text-base text-black">{limit.unit}</Text>
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
</TouchableOpacity>
{props.durationLimits.length > 1 && (
{props.durationLimits.length > 1 ? (
<TouchableOpacity
className="h-10 w-10 items-center justify-center rounded-lg border border-[#FFCCC7] bg-[#FFF1F0]"
onPress={() => props.removeDurationLimit(limit.id)}
>
<Ionicons name="trash-outline" size={20} color="#800000" />
</TouchableOpacity>
)}
) : null}
</View>
))}
<TouchableOpacity
@@ -328,7 +328,7 @@ export function LimitsTab(props: LimitsTabProps) {
<Text className="text-base font-medium text-black">Add Limit</Text>
</TouchableOpacity>
</>
)}
) : null}
</Animated.View>
</View>
@@ -350,7 +350,7 @@ export function LimitsTab(props: LimitsTabProps) {
thumbColor="#FFFFFF"
/>
</View>
{props.maxActiveBookingsPerBooker && (
{props.maxActiveBookingsPerBooker ? (
<View className="mt-4 gap-3">
<View className="flex-row items-center gap-3">
<TextInput
@@ -385,7 +385,7 @@ export function LimitsTab(props: LimitsTabProps) {
</Text>
</TouchableOpacity>
</View>
)}
) : null}
</View>
{/* 5. Limit Future Bookings Card */}
@@ -404,7 +404,7 @@ export function LimitsTab(props: LimitsTabProps) {
thumbColor="#FFFFFF"
/>
</View>
{props.limitFutureBookings && (
{props.limitFutureBookings ? (
<View className="mt-4 gap-3">
{/* Rolling option */}
<TouchableOpacity
@@ -416,9 +416,9 @@ export function LimitsTab(props: LimitsTabProps) {
props.futureBookingType === "rolling" ? "border-[#007AFF]" : "border-[#C7C7CC]"
}`}
>
{props.futureBookingType === "rolling" && (
{props.futureBookingType === "rolling" ? (
<View className="h-2.5 w-2.5 rounded-full bg-[#007AFF]" />
)}
) : null}
</View>
<View className="flex-1">
<View className="flex-row flex-wrap items-center gap-2">
@@ -460,13 +460,13 @@ export function LimitsTab(props: LimitsTabProps) {
props.futureBookingType === "range" ? "border-[#007AFF]" : "border-[#C7C7CC]"
}`}
>
{props.futureBookingType === "range" && (
{props.futureBookingType === "range" ? (
<View className="h-2.5 w-2.5 rounded-full bg-[#007AFF]" />
)}
) : null}
</View>
<View className="flex-1">
<Text className="mb-2 text-base text-[#333]">Within a date range</Text>
{props.futureBookingType === "range" && (
{props.futureBookingType === "range" ? (
<View className="gap-2">
<TextInput
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-2 text-base text-black"
@@ -483,11 +483,11 @@ export function LimitsTab(props: LimitsTabProps) {
placeholderTextColor="#8E8E93"
/>
</View>
)}
) : null}
</View>
</TouchableOpacity>
</View>
)}
) : null}
</View>
</View>
);
@@ -64,7 +64,7 @@ export function RecurringTab({
</View>
{/* Recurring Configuration - shown when enabled */}
{recurringEnabled && (
{recurringEnabled ? (
<View className="mt-4 gap-4 border-t border-[#E5E5EA] pt-4">
{/* Repeats Every */}
<View>
@@ -126,7 +126,7 @@ export function RecurringTab({
</View>
</View>
</View>
)}
) : null}
</View>
</View>
);
@@ -87,11 +87,11 @@ export const EventTypeListItem = ({
<View className="mb-1 flex-row items-center">
<Text className="flex-1 text-base font-semibold text-[#333]">{item.title} </Text>
</View>
{item.description && (
{item.description ? (
<Text className="mb-2 mt-0.5 text-sm leading-5 text-[#666]" numberOfLines={2}>
{normalizeMarkdown(item.description)}
</Text>
)}
) : null}
<View className="mt-2 flex-row items-center self-start rounded-lg border border-[#E5E5EA] bg-[#E5E5EA] px-2 py-1">
<Ionicons name="time-outline" size={14} color="#000" />
<Text className="ml-1.5 text-xs font-semibold text-black">
@@ -100,17 +100,17 @@ export const EventTypeListItem = ({
</View>
{(item.price != null && item.price > 0) || item.requiresConfirmation ? (
<View className="mt-2 flex-row items-center gap-3">
{item.price != null && item.price > 0 && (
{item.price != null && item.price > 0 ? (
<Text className="text-sm font-medium text-[#34C759]">
{item.currency || "$"}
{item.price}
</Text>
)}
{item.requiresConfirmation && (
) : null}
{item.requiresConfirmation ? (
<View className="rounded bg-[#FF9500] px-2 py-0.5">
<Text className="text-xs font-medium text-white">Requires Confirmation</Text>
</View>
)}
) : null}
</View>
) : null}
</View>
@@ -45,11 +45,11 @@ export const EventTypeListItem = ({
<View className="mb-1 flex-row items-center">
<Text className="flex-1 text-base font-semibold text-[#333]">{item.title}</Text>
</View>
{item.description && (
{item.description ? (
<Text className="mb-2 mt-0.5 text-sm leading-5 text-[#666]" numberOfLines={2}>
{normalizeMarkdown(item.description)}
</Text>
)}
) : null}
<View className="mt-2 flex-row items-center self-start rounded-lg border border-[#E5E5EA] bg-[#E5E5EA] px-2 py-1">
<Ionicons name="time-outline" size={14} color="#000" />
<Text className="ml-1.5 text-xs font-semibold text-black">
@@ -58,17 +58,17 @@ export const EventTypeListItem = ({
</View>
{(item.price != null && item.price > 0) || item.requiresConfirmation ? (
<View className="mt-2 flex-row items-center gap-3">
{item.price != null && item.price > 0 && (
{item.price != null && item.price > 0 ? (
<Text className="text-sm font-medium text-[#34C759]">
{item.currency || "$"}
{item.price}
</Text>
)}
{item.requiresConfirmation && (
) : null}
{item.requiresConfirmation ? (
<View className="rounded bg-[#FF9500] px-2 py-0.5">
<Text className="text-xs font-medium text-white">Requires Confirmation</Text>
</View>
)}
) : null}
</View>
) : null}
</View>
@@ -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<string> {
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<void> {
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<boolean> {
}
async function getAuthHeader(): Promise<string> {
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) {
+27 -18
View File
@@ -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();
+17
View File
@@ -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 {
+20 -6
View File
@@ -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"
+124 -2
View File
@@ -238,6 +238,7 @@ export class CalComOAuthService {
private async launchExtensionAuthFlow(authUrl: string): Promise<string> {
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<OAuthConfig> = {}): 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,
};
+75 -1
View File
@@ -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(