mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 17:21:05 +00:00
refactor: consolidate and debloat calendar sync implementation
Merge duplicated Google/Outlook engines, background handlers, and API layers into shared calendarSync modules to cut complexity without changing user-facing sync behavior.
This commit is contained in:
@@ -0,0 +1,531 @@
|
||||
import {
|
||||
GOOGLE_AUTH_URL,
|
||||
GOOGLE_CALENDAR_ACCOUNTS_NOT_READY_HINT,
|
||||
GOOGLE_CALENDAR_OAUTH_CALLBACK,
|
||||
GOOGLE_CALENDAR_REFRESH_URL,
|
||||
GOOGLE_CALENDAR_SCOPE,
|
||||
GOOGLE_CALENDAR_TOKEN_URL,
|
||||
GOOGLE_OAUTH_CLIENT_ID,
|
||||
googleOAuthRedirectUriHint,
|
||||
isGoogleCalendarConfigured,
|
||||
} from "@/config/googleCalendar";
|
||||
import {
|
||||
OUTLOOK_AUTH_URL,
|
||||
OUTLOOK_CALENDAR_ACCOUNTS_NOT_READY_HINT,
|
||||
OUTLOOK_CALENDAR_OAUTH_CALLBACK,
|
||||
OUTLOOK_CALENDAR_REFRESH_URL,
|
||||
OUTLOOK_CALENDAR_SCOPE,
|
||||
OUTLOOK_CALENDAR_TOKEN_URL,
|
||||
OUTLOOK_OAUTH_CLIENT_ID,
|
||||
isOutlookCalendarConfigured,
|
||||
outlookOAuthRedirectUriHint,
|
||||
} from "@/config/outlookCalendar";
|
||||
import {
|
||||
exchangeAccountsCode,
|
||||
refreshAccountsToken,
|
||||
type AccountsTokenPayload,
|
||||
} from "@/seqta/utils/calendarSync/accountsToken";
|
||||
import {
|
||||
CALENDAR_WEEKLY_ALARM,
|
||||
clampSyncWeeks,
|
||||
getAutoSyncWeekly,
|
||||
getSyncWeeksAhead,
|
||||
isAnyCalendarConnected,
|
||||
markWeeklySyncPending,
|
||||
readSharedCalendarSyncSettings,
|
||||
writeSharedCalendarSyncSettings,
|
||||
} from "@/seqta/utils/calendarSync/settings";
|
||||
import {
|
||||
clearGoogleCalendarState,
|
||||
readGoogleCalendarState,
|
||||
writeGoogleCalendarState,
|
||||
} from "@/seqta/utils/googleCalendar/storage";
|
||||
import type {
|
||||
GoogleCalendarStatus,
|
||||
GoogleCalendarSyncResult,
|
||||
} from "@/seqta/utils/googleCalendar/types";
|
||||
import {
|
||||
clearOutlookCalendarState,
|
||||
readOutlookCalendarState,
|
||||
writeOutlookCalendarState,
|
||||
} from "@/seqta/utils/outlookCalendar/storage";
|
||||
import type { OutlookCalendarStatus } from "@/seqta/utils/outlookCalendar/storage";
|
||||
import browser from "webextension-polyfill";
|
||||
|
||||
const WEEKLY_PERIOD_MINUTES = 7 * 24 * 60;
|
||||
|
||||
export type CalendarMessageHandler = (
|
||||
request: unknown,
|
||||
sendResponse: (response?: unknown) => void,
|
||||
sender?: browser.Runtime.MessageSender,
|
||||
) => boolean | void;
|
||||
|
||||
export type CalendarMessageHandlerMap = Record<string, CalendarMessageHandler>;
|
||||
|
||||
type StoredTokens = {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
lastSyncAt?: number;
|
||||
lastSyncOrigin?: string;
|
||||
};
|
||||
|
||||
type OAuthTabFlowOptions = {
|
||||
callbackPrefix: string;
|
||||
timeoutMessage: string;
|
||||
cancelledMessage: string;
|
||||
tabOpenErrorMessage: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type CalendarProviderBackend = {
|
||||
label: string;
|
||||
isConfigured: () => boolean;
|
||||
redirectHint: () => string;
|
||||
mismatchRe: RegExp;
|
||||
authUrl: string;
|
||||
clientId: string;
|
||||
scope: string;
|
||||
callback: string;
|
||||
tabOpts: Omit<OAuthTabFlowOptions, "callbackPrefix">;
|
||||
notConfiguredError: string;
|
||||
notConnectedError: string;
|
||||
signInFailed: string;
|
||||
read: () => Promise<StoredTokens>;
|
||||
write: (patch: Partial<StoredTokens> & { connectedAt?: number }) => Promise<unknown>;
|
||||
clear: () => Promise<unknown>;
|
||||
exchange: (code: string, redirectUri: string, verifier: string) => Promise<AccountsTokenPayload>;
|
||||
refresh: (refreshToken: string) => Promise<AccountsTokenPayload>;
|
||||
applyAuthParams: (authUrl: URL) => void;
|
||||
messagePrefix: string;
|
||||
};
|
||||
|
||||
function createProviderBackend(
|
||||
config: Omit<CalendarProviderBackend, "exchange" | "refresh"> & {
|
||||
tokenUrl: string;
|
||||
refreshUrl: string;
|
||||
notReadyHint: string;
|
||||
includeErrorDescription?: boolean;
|
||||
},
|
||||
): CalendarProviderBackend {
|
||||
const { tokenUrl, refreshUrl, notReadyHint, includeErrorDescription, ...backend } = config;
|
||||
return {
|
||||
...backend,
|
||||
exchange: (code, redirectUri, verifier) =>
|
||||
exchangeAccountsCode(tokenUrl, code, redirectUri, verifier, notReadyHint, includeErrorDescription),
|
||||
refresh: (refreshToken) =>
|
||||
refreshAccountsToken(refreshUrl, refreshToken, notReadyHint, includeErrorDescription),
|
||||
};
|
||||
}
|
||||
|
||||
const GOOGLE_BACKEND = createProviderBackend({
|
||||
label: "Google",
|
||||
isConfigured: isGoogleCalendarConfigured,
|
||||
redirectHint: googleOAuthRedirectUriHint,
|
||||
mismatchRe: /redirect_uri_mismatch|invalid_request/i,
|
||||
authUrl: GOOGLE_AUTH_URL,
|
||||
clientId: GOOGLE_OAUTH_CLIENT_ID,
|
||||
scope: GOOGLE_CALENDAR_SCOPE,
|
||||
callback: GOOGLE_CALENDAR_OAUTH_CALLBACK,
|
||||
tabOpts: {
|
||||
timeoutMessage: "Google sign-in timed out. Close the tab and try again.",
|
||||
cancelledMessage: "Google sign-in was cancelled.",
|
||||
tabOpenErrorMessage: "Could not open Google sign-in tab.",
|
||||
},
|
||||
notConfiguredError: "Google Calendar is not configured in this extension build.",
|
||||
notConnectedError: "Not connected to Google Calendar.",
|
||||
signInFailed: "Google sign-in failed",
|
||||
read: readGoogleCalendarState,
|
||||
write: writeGoogleCalendarState,
|
||||
clear: clearGoogleCalendarState,
|
||||
applyAuthParams(authUrl) {
|
||||
authUrl.searchParams.set("access_type", "offline");
|
||||
},
|
||||
messagePrefix: "googleCalendar",
|
||||
tokenUrl: GOOGLE_CALENDAR_TOKEN_URL,
|
||||
refreshUrl: GOOGLE_CALENDAR_REFRESH_URL,
|
||||
notReadyHint: GOOGLE_CALENDAR_ACCOUNTS_NOT_READY_HINT,
|
||||
});
|
||||
|
||||
const OUTLOOK_BACKEND = createProviderBackend({
|
||||
label: "Microsoft",
|
||||
isConfigured: isOutlookCalendarConfigured,
|
||||
redirectHint: outlookOAuthRedirectUriHint,
|
||||
mismatchRe: /redirect_uri|invalid_request|AADSTS50011/i,
|
||||
authUrl: OUTLOOK_AUTH_URL,
|
||||
clientId: OUTLOOK_OAUTH_CLIENT_ID,
|
||||
scope: OUTLOOK_CALENDAR_SCOPE,
|
||||
callback: OUTLOOK_CALENDAR_OAUTH_CALLBACK,
|
||||
tabOpts: {
|
||||
timeoutMessage: "Microsoft sign-in timed out. Close the tab and try again.",
|
||||
cancelledMessage: "Microsoft sign-in was cancelled.",
|
||||
tabOpenErrorMessage: "Could not open Microsoft sign-in tab.",
|
||||
},
|
||||
notConfiguredError: "Outlook Calendar is not configured in this extension build.",
|
||||
notConnectedError: "Not connected to Outlook Calendar.",
|
||||
signInFailed: "Microsoft sign-in failed",
|
||||
read: readOutlookCalendarState,
|
||||
write: writeOutlookCalendarState,
|
||||
clear: clearOutlookCalendarState,
|
||||
applyAuthParams(authUrl) {
|
||||
authUrl.searchParams.set("response_mode", "query");
|
||||
},
|
||||
messagePrefix: "outlookCalendar",
|
||||
tokenUrl: OUTLOOK_CALENDAR_TOKEN_URL,
|
||||
refreshUrl: OUTLOOK_CALENDAR_REFRESH_URL,
|
||||
notReadyHint: OUTLOOK_CALENDAR_ACCOUNTS_NOT_READY_HINT,
|
||||
includeErrorDescription: true,
|
||||
});
|
||||
|
||||
function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (const b of bytes) binary += String.fromCharCode(b);
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
function randomPkceVerifier(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return base64UrlEncode(bytes.buffer);
|
||||
}
|
||||
|
||||
async function pkceChallenge(verifier: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(verifier);
|
||||
const digest = await crypto.subtle.digest("SHA-256", data);
|
||||
return base64UrlEncode(digest);
|
||||
}
|
||||
|
||||
function parseOAuthRedirectCode(responseUrl: string, providerLabel: string): string {
|
||||
const url = new URL(responseUrl);
|
||||
const err = url.searchParams.get("error");
|
||||
if (err) throw new Error(url.searchParams.get("error_description") ?? err);
|
||||
const code = url.searchParams.get("code");
|
||||
if (!code) throw new Error(`${providerLabel} sign-in did not return an authorization code.`);
|
||||
return code;
|
||||
}
|
||||
|
||||
function isOAuthCallbackUrl(url: string, callbackPrefix: string): boolean {
|
||||
if (!url.startsWith(callbackPrefix)) return false;
|
||||
const parsed = new URL(url);
|
||||
return parsed.searchParams.has("code") || parsed.searchParams.has("error");
|
||||
}
|
||||
|
||||
function waitForOAuthCallback(authTabId: number, opts: OAuthTabFlowOptions): Promise<string> {
|
||||
const timeoutMs = opts.timeoutMs ?? 10 * 60 * 1000;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error(opts.timeoutMessage));
|
||||
}, timeoutMs);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeoutId);
|
||||
browser.tabs.onUpdated.removeListener(onUpdated);
|
||||
browser.tabs.onRemoved.removeListener(onRemoved);
|
||||
};
|
||||
|
||||
const finishFromUrl = (url: string, tabId: number) => {
|
||||
if (!isOAuthCallbackUrl(url, opts.callbackPrefix)) return false;
|
||||
cleanup();
|
||||
void browser.tabs.remove(tabId).catch(() => {});
|
||||
resolve(url);
|
||||
return true;
|
||||
};
|
||||
|
||||
const onRemoved = (tabId: number) => {
|
||||
if (tabId !== authTabId) return;
|
||||
cleanup();
|
||||
reject(new Error(opts.cancelledMessage));
|
||||
};
|
||||
|
||||
const onUpdated = (
|
||||
tabId: number,
|
||||
changeInfo: browser.Tabs.OnUpdatedChangeInfoType,
|
||||
tab: browser.Tabs.Tab,
|
||||
) => {
|
||||
if (tabId !== authTabId) return;
|
||||
const url =
|
||||
changeInfo.url ?? (changeInfo.status === "complete" ? tab.url : undefined);
|
||||
if (url) finishFromUrl(url, tabId);
|
||||
};
|
||||
|
||||
browser.tabs.onUpdated.addListener(onUpdated);
|
||||
browser.tabs.onRemoved.addListener(onRemoved);
|
||||
|
||||
void browser.tabs
|
||||
.get(authTabId)
|
||||
.then((tab) => {
|
||||
if (tab.url) finishFromUrl(tab.url, authTabId);
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
async function openOAuthTab(authUrl: string, opts: OAuthTabFlowOptions): Promise<string> {
|
||||
const tab = await browser.tabs.create({ url: authUrl, active: true });
|
||||
if (tab.id === undefined) throw new Error(opts.tabOpenErrorMessage);
|
||||
return waitForOAuthCallback(tab.id, opts);
|
||||
}
|
||||
|
||||
function tokenExpiresAt(expiresIn?: number): number {
|
||||
return expiresIn ? Date.now() + expiresIn * 1000 : Date.now() + 3_600_000;
|
||||
}
|
||||
|
||||
async function getValidStoredAccessToken(
|
||||
provider: CalendarProviderBackend,
|
||||
): Promise<string> {
|
||||
const state = await provider.read();
|
||||
const now = Date.now();
|
||||
if (state.accessToken && state.expiresAt && state.expiresAt > now + 60_000) {
|
||||
return state.accessToken;
|
||||
}
|
||||
if (!state.refreshToken) throw new Error(provider.notConnectedError);
|
||||
const refreshed = await provider.refresh(state.refreshToken);
|
||||
await provider.write({
|
||||
accessToken: refreshed.access_token,
|
||||
refreshToken: refreshed.refresh_token ?? state.refreshToken,
|
||||
expiresAt: tokenExpiresAt(refreshed.expires_in),
|
||||
});
|
||||
return refreshed.access_token;
|
||||
}
|
||||
|
||||
export function registerTrustedAsyncHandler(
|
||||
handlers: CalendarMessageHandlerMap,
|
||||
isTrustedSender: (sender?: browser.Runtime.MessageSender) => boolean,
|
||||
key: string,
|
||||
fn: (request: unknown) => Promise<unknown>,
|
||||
onError?: (err: unknown) => unknown,
|
||||
): void {
|
||||
handlers[key] = (request, sendResponse, sender) => {
|
||||
if (!isTrustedSender(sender)) {
|
||||
sendResponse({ success: false, error: "Unauthorized sender" });
|
||||
return false;
|
||||
}
|
||||
void fn(request)
|
||||
.then(sendResponse)
|
||||
.catch((err) => {
|
||||
sendResponse(
|
||||
onError?.(err) ?? {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Request failed",
|
||||
},
|
||||
);
|
||||
});
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
async function getGoogleCalendarStatus(): Promise<GoogleCalendarStatus> {
|
||||
const [state, shared, syncWeeksAhead, autoSyncWeekly] = await Promise.all([
|
||||
readGoogleCalendarState(),
|
||||
readSharedCalendarSyncSettings(),
|
||||
getSyncWeeksAhead(),
|
||||
getAutoSyncWeekly(),
|
||||
]);
|
||||
return {
|
||||
configured: isGoogleCalendarConfigured(),
|
||||
connected: !!(state.refreshToken || state.accessToken),
|
||||
lastSyncAt: state.lastSyncAt,
|
||||
lastWeeklySyncAt: shared.lastWeeklySyncAt,
|
||||
lastSyncOrigin: state.lastSyncOrigin,
|
||||
syncWeeksAhead,
|
||||
autoSyncWeekly,
|
||||
};
|
||||
}
|
||||
|
||||
async function getOutlookCalendarStatus(): Promise<OutlookCalendarStatus> {
|
||||
const state = await readOutlookCalendarState();
|
||||
return {
|
||||
configured: isOutlookCalendarConfigured(),
|
||||
connected: !!(state.refreshToken || state.accessToken),
|
||||
lastSyncAt: state.lastSyncAt,
|
||||
lastSyncOrigin: state.lastSyncOrigin,
|
||||
};
|
||||
}
|
||||
|
||||
async function connectCalendar(provider: CalendarProviderBackend): Promise<GoogleCalendarSyncResult> {
|
||||
if (!provider.isConfigured()) {
|
||||
return { success: false, configured: false, error: provider.notConfiguredError };
|
||||
}
|
||||
|
||||
const redirectUri = provider.callback;
|
||||
const verifier = randomPkceVerifier();
|
||||
const challenge = await pkceChallenge(verifier);
|
||||
const authUrl = new URL(provider.authUrl);
|
||||
authUrl.searchParams.set("client_id", provider.clientId);
|
||||
authUrl.searchParams.set("response_type", "code");
|
||||
authUrl.searchParams.set("redirect_uri", redirectUri);
|
||||
authUrl.searchParams.set("scope", provider.scope);
|
||||
authUrl.searchParams.set("prompt", "consent");
|
||||
authUrl.searchParams.set("code_challenge", challenge);
|
||||
authUrl.searchParams.set("code_challenge_method", "S256");
|
||||
provider.applyAuthParams(authUrl);
|
||||
|
||||
let responseUrl: string;
|
||||
try {
|
||||
responseUrl = await openOAuthTab(authUrl.toString(), {
|
||||
callbackPrefix: provider.callback,
|
||||
...provider.tabOpts,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : provider.signInFailed;
|
||||
const mismatch =
|
||||
provider.mismatchRe.test(message) || provider.mismatchRe.test(String(err));
|
||||
return {
|
||||
success: false,
|
||||
configured: true,
|
||||
error: mismatch
|
||||
? `${provider.label} redirect URI mismatch. ${provider.redirectHint()}`
|
||||
: message.includes("cancel")
|
||||
? provider.tabOpts.cancelledMessage
|
||||
: message,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const code = parseOAuthRedirectCode(responseUrl, provider.label);
|
||||
const tokens = await provider.exchange(code, redirectUri, verifier);
|
||||
const existing = await provider.read();
|
||||
await provider.write({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token ?? existing.refreshToken,
|
||||
expiresAt: tokenExpiresAt(tokens.expires_in),
|
||||
connectedAt: Date.now(),
|
||||
});
|
||||
await ensureWeeklySyncAlarm();
|
||||
return { success: true, configured: true, connected: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
configured: true,
|
||||
error: err instanceof Error ? err.message : provider.signInFailed,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function registerProviderHandlers(
|
||||
handlers: CalendarMessageHandlerMap,
|
||||
isTrustedSender: (sender?: browser.Runtime.MessageSender) => boolean,
|
||||
provider: CalendarProviderBackend,
|
||||
getStatus: () => Promise<unknown>,
|
||||
): void {
|
||||
const { messagePrefix, signInFailed } = provider;
|
||||
const errMsg = (err: unknown, fallback: string) =>
|
||||
err instanceof Error ? err.message : fallback;
|
||||
|
||||
registerTrustedAsyncHandler(
|
||||
handlers,
|
||||
isTrustedSender,
|
||||
`${messagePrefix}Connect`,
|
||||
() => connectCalendar(provider),
|
||||
(err) => ({ success: false, error: errMsg(err, signInFailed) }),
|
||||
);
|
||||
|
||||
registerTrustedAsyncHandler(
|
||||
handlers,
|
||||
isTrustedSender,
|
||||
`${messagePrefix}Disconnect`,
|
||||
async () => {
|
||||
await provider.clear();
|
||||
await ensureWeeklySyncAlarm();
|
||||
return { success: true };
|
||||
},
|
||||
(err) => ({ success: false, error: errMsg(err, "Disconnect failed") }),
|
||||
);
|
||||
|
||||
registerTrustedAsyncHandler(
|
||||
handlers,
|
||||
isTrustedSender,
|
||||
`${messagePrefix}Status`,
|
||||
getStatus,
|
||||
() => ({ configured: provider.isConfigured(), connected: false }),
|
||||
);
|
||||
|
||||
registerTrustedAsyncHandler(
|
||||
handlers,
|
||||
isTrustedSender,
|
||||
`${messagePrefix}GetAccessToken`,
|
||||
() => getValidStoredAccessToken(provider).then((accessToken) => ({ success: true, accessToken })),
|
||||
(err) => ({ success: false, error: errMsg(err, "Token refresh failed") }),
|
||||
);
|
||||
}
|
||||
|
||||
export function registerGoogleCalendarMessageHandlers(
|
||||
handlers: CalendarMessageHandlerMap,
|
||||
isTrustedSender: (sender?: browser.Runtime.MessageSender) => boolean,
|
||||
): void {
|
||||
registerProviderHandlers(handlers, isTrustedSender, GOOGLE_BACKEND, getGoogleCalendarStatus);
|
||||
|
||||
registerTrustedAsyncHandler(handlers, isTrustedSender, "googleCalendarEnsureWeeklyAlarm", async () => {
|
||||
await ensureWeeklySyncAlarm();
|
||||
return { success: true };
|
||||
}, (err) => ({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Could not schedule weekly sync",
|
||||
}));
|
||||
|
||||
registerTrustedAsyncHandler(handlers, isTrustedSender, "googleCalendarUpdateSyncSettings", async (request) => {
|
||||
const body = request as { syncWeeksAhead?: number; autoSyncWeekly?: boolean };
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (body.syncWeeksAhead != null) patch.syncWeeksAhead = clampSyncWeeks(body.syncWeeksAhead);
|
||||
if (body.autoSyncWeekly != null) patch.autoSyncWeekly = !!body.autoSyncWeekly;
|
||||
if (Object.keys(patch).length > 0) await writeSharedCalendarSyncSettings(patch);
|
||||
await ensureWeeklySyncAlarm();
|
||||
return { success: true, ...(await getGoogleCalendarStatus()) };
|
||||
}, (err) => ({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Could not update sync settings",
|
||||
}));
|
||||
}
|
||||
|
||||
export function registerOutlookCalendarMessageHandlers(
|
||||
handlers: CalendarMessageHandlerMap,
|
||||
isTrustedSender: (sender?: browser.Runtime.MessageSender) => boolean,
|
||||
): void {
|
||||
registerProviderHandlers(handlers, isTrustedSender, OUTLOOK_BACKEND, getOutlookCalendarStatus);
|
||||
}
|
||||
|
||||
function isSeqtaTab(tab: browser.Tabs.Tab): boolean {
|
||||
const title = tab.title ?? "";
|
||||
return title.includes("SEQTA Learn") || title.includes("SEQTA Engage");
|
||||
}
|
||||
|
||||
export async function ensureWeeklySyncAlarm(): Promise<void> {
|
||||
const [connected, enabled] = await Promise.all([isAnyCalendarConnected(), getAutoSyncWeekly()]);
|
||||
if (!connected || !enabled) {
|
||||
await browser.alarms.clear(CALENDAR_WEEKLY_ALARM);
|
||||
return;
|
||||
}
|
||||
const existing = await browser.alarms.get(CALENDAR_WEEKLY_ALARM);
|
||||
if (!existing) {
|
||||
await browser.alarms.create(CALENDAR_WEEKLY_ALARM, { periodInMinutes: WEEKLY_PERIOD_MINUTES });
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerWeeklySyncOnSeqtaTabs(): Promise<boolean> {
|
||||
const tabs = await browser.tabs.query({});
|
||||
let delivered = false;
|
||||
for (const tab of tabs) {
|
||||
if (tab.id == null || !isSeqtaTab(tab)) continue;
|
||||
try {
|
||||
await browser.tabs.sendMessage(tab.id, { type: "calendarRunWeeklySync" });
|
||||
delivered = true;
|
||||
} catch {
|
||||
// Tab may not have content script yet.
|
||||
}
|
||||
}
|
||||
return delivered;
|
||||
}
|
||||
|
||||
async function handleWeeklySyncAlarm(): Promise<void> {
|
||||
if (!(await isAnyCalendarConnected()) || !(await getAutoSyncWeekly())) return;
|
||||
if (!(await triggerWeeklySyncOnSeqtaTabs())) await markWeeklySyncPending();
|
||||
}
|
||||
|
||||
export function initCalendarBackground(): void {
|
||||
browser.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === CALENDAR_WEEKLY_ALARM) void handleWeeklySyncAlarm();
|
||||
});
|
||||
void ensureWeeklySyncAlarm();
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import { verboseLog } from "@/utils/verboseLog";
|
||||
import {
|
||||
CALENDAR_WEEKLY_ALARM,
|
||||
getAutoSyncWeekly,
|
||||
markWeeklySyncPending,
|
||||
} from "@/seqta/utils/calendarSync/settings";
|
||||
import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/storage";
|
||||
import { readOutlookCalendarState } from "@/seqta/utils/outlookCalendar/storage";
|
||||
|
||||
const WEEKLY_PERIOD_MINUTES = 7 * 24 * 60;
|
||||
|
||||
function isSeqtaTab(tab: browser.Tabs.Tab): boolean {
|
||||
const title = tab.title ?? "";
|
||||
return title.includes("SEQTA Learn") || title.includes("SEQTA Engage");
|
||||
}
|
||||
|
||||
async function isAnyCalendarConnected(): Promise<boolean> {
|
||||
const [google, outlook] = await Promise.all([
|
||||
readGoogleCalendarState(),
|
||||
readOutlookCalendarState(),
|
||||
]);
|
||||
return !!(
|
||||
google.refreshToken ||
|
||||
google.accessToken ||
|
||||
outlook.refreshToken ||
|
||||
outlook.accessToken
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureWeeklySyncAlarm(): Promise<void> {
|
||||
const connected = await isAnyCalendarConnected();
|
||||
const enabled = await getAutoSyncWeekly();
|
||||
if (!connected || !enabled) {
|
||||
await browser.alarms.clear(CALENDAR_WEEKLY_ALARM);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await browser.alarms.get(CALENDAR_WEEKLY_ALARM);
|
||||
if (!existing) {
|
||||
await browser.alarms.create(CALENDAR_WEEKLY_ALARM, {
|
||||
periodInMinutes: WEEKLY_PERIOD_MINUTES,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearWeeklySyncAlarm(): Promise<void> {
|
||||
await browser.alarms.clear(CALENDAR_WEEKLY_ALARM);
|
||||
}
|
||||
|
||||
export async function triggerWeeklySyncOnSeqtaTabs(): Promise<boolean> {
|
||||
const tabs = await browser.tabs.query({});
|
||||
const seqtaTabs = tabs.filter((tab) => tab.id != null && isSeqtaTab(tab));
|
||||
if (seqtaTabs.length === 0) return false;
|
||||
|
||||
let delivered = false;
|
||||
for (const tab of seqtaTabs) {
|
||||
if (tab.id == null) continue;
|
||||
try {
|
||||
await browser.tabs.sendMessage(tab.id, { type: "calendarRunWeeklySync" });
|
||||
delivered = true;
|
||||
} catch (err) {
|
||||
verboseLog("[BetterSEQTA+] Weekly calendar sync message failed for tab:", tab.id, err);
|
||||
}
|
||||
}
|
||||
return delivered;
|
||||
}
|
||||
|
||||
export async function handleWeeklySyncAlarm(): Promise<void> {
|
||||
if (!(await isAnyCalendarConnected()) || !(await getAutoSyncWeekly())) return;
|
||||
|
||||
const delivered = await triggerWeeklySyncOnSeqtaTabs();
|
||||
if (!delivered) {
|
||||
await markWeeklySyncPending();
|
||||
}
|
||||
}
|
||||
|
||||
export function registerWeeklySyncAlarmListener(): void {
|
||||
browser.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name !== CALENDAR_WEEKLY_ALARM) return;
|
||||
void handleWeeklySyncAlarm();
|
||||
});
|
||||
}
|
||||
|
||||
export function initCalendarBackground(): void {
|
||||
registerWeeklySyncAlarmListener();
|
||||
void ensureWeeklySyncAlarm();
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import {
|
||||
GOOGLE_AUTH_URL,
|
||||
GOOGLE_CALENDAR_OAUTH_CALLBACK,
|
||||
GOOGLE_CALENDAR_SCOPE,
|
||||
GOOGLE_OAUTH_CLIENT_ID,
|
||||
googleOAuthRedirectUriHint,
|
||||
isGoogleCalendarConfigured,
|
||||
} from "@/config/googleCalendar";
|
||||
import {
|
||||
exchangeGoogleCodeViaAccounts,
|
||||
refreshGoogleTokenViaAccounts,
|
||||
} from "@/seqta/utils/googleCalendar/accountsToken";
|
||||
import {
|
||||
clearGoogleCalendarState,
|
||||
readGoogleCalendarState,
|
||||
writeGoogleCalendarState,
|
||||
} from "@/seqta/utils/googleCalendar/storage";
|
||||
import {
|
||||
clampSyncWeeks,
|
||||
getAutoSyncWeekly,
|
||||
getSyncWeeksAhead,
|
||||
} from "@/seqta/utils/calendarSync/settings";
|
||||
import {
|
||||
readSharedCalendarSyncSettings,
|
||||
writeSharedCalendarSyncSettings,
|
||||
} from "@/seqta/utils/calendarSync/sharedSettings";
|
||||
import type {
|
||||
GoogleCalendarStatus,
|
||||
GoogleCalendarSyncResult,
|
||||
} from "@/seqta/utils/googleCalendar/types";
|
||||
import { ensureWeeklySyncAlarm, initCalendarBackground } from "./calendarWeekly";
|
||||
|
||||
function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (const b of bytes) binary += String.fromCharCode(b);
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
async function sha256(input: string): Promise<ArrayBuffer> {
|
||||
const data = new TextEncoder().encode(input);
|
||||
return crypto.subtle.digest("SHA-256", data);
|
||||
}
|
||||
|
||||
function randomVerifier(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return base64UrlEncode(bytes.buffer);
|
||||
}
|
||||
|
||||
async function pkceChallenge(verifier: string): Promise<string> {
|
||||
return base64UrlEncode(await sha256(verifier));
|
||||
}
|
||||
|
||||
function parseRedirectCode(responseUrl: string): string {
|
||||
const url = new URL(responseUrl);
|
||||
const err = url.searchParams.get("error");
|
||||
if (err) throw new Error(url.searchParams.get("error_description") ?? err);
|
||||
const code = url.searchParams.get("code");
|
||||
if (!code) throw new Error("Google sign-in did not return an authorization code.");
|
||||
return code;
|
||||
}
|
||||
|
||||
const GOOGLE_OAUTH_CALLBACK_PREFIX = GOOGLE_CALENDAR_OAUTH_CALLBACK;
|
||||
const GOOGLE_OAUTH_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
function isGoogleOAuthCallbackUrl(url: string): boolean {
|
||||
if (!url.startsWith(GOOGLE_OAUTH_CALLBACK_PREFIX)) return false;
|
||||
const parsed = new URL(url);
|
||||
return parsed.searchParams.has("code") || parsed.searchParams.has("error");
|
||||
}
|
||||
|
||||
/** Tab-based OAuth (same pattern as cloud login) — launchWebAuthFlow does not reliably return external redirect URLs. */
|
||||
function waitForGoogleOAuthCallback(authTabId: number): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Google sign-in timed out. Close the tab and try again."));
|
||||
}, GOOGLE_OAUTH_TIMEOUT_MS);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeoutId);
|
||||
browser.tabs.onUpdated.removeListener(onUpdated);
|
||||
browser.tabs.onRemoved.removeListener(onRemoved);
|
||||
};
|
||||
|
||||
const finishFromUrl = (url: string, tabId: number) => {
|
||||
if (!isGoogleOAuthCallbackUrl(url)) return false;
|
||||
cleanup();
|
||||
void browser.tabs.remove(tabId).catch(() => {});
|
||||
resolve(url);
|
||||
return true;
|
||||
};
|
||||
|
||||
const onRemoved = (tabId: number) => {
|
||||
if (tabId !== authTabId) return;
|
||||
cleanup();
|
||||
reject(new Error("Google sign-in was cancelled."));
|
||||
};
|
||||
|
||||
const onUpdated = (
|
||||
tabId: number,
|
||||
changeInfo: browser.Tabs.OnUpdatedChangeInfoType,
|
||||
tab: browser.Tabs.Tab,
|
||||
) => {
|
||||
if (tabId !== authTabId) return;
|
||||
const url =
|
||||
changeInfo.url ?? (changeInfo.status === "complete" ? tab.url : undefined);
|
||||
if (url) finishFromUrl(url, tabId);
|
||||
};
|
||||
|
||||
browser.tabs.onUpdated.addListener(onUpdated);
|
||||
browser.tabs.onRemoved.addListener(onRemoved);
|
||||
|
||||
void browser.tabs
|
||||
.get(authTabId)
|
||||
.then((tab) => {
|
||||
if (tab.url) finishFromUrl(tab.url, authTabId);
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
async function openGoogleOAuthTab(authUrl: string): Promise<string> {
|
||||
const tab = await browser.tabs.create({ url: authUrl, active: true });
|
||||
if (tab.id === undefined) {
|
||||
throw new Error("Could not open Google sign-in tab.");
|
||||
}
|
||||
return waitForGoogleOAuthCallback(tab.id);
|
||||
}
|
||||
|
||||
async function getValidAccessToken(): Promise<string> {
|
||||
const state = await readGoogleCalendarState();
|
||||
const now = Date.now();
|
||||
if (state.accessToken && state.expiresAt && state.expiresAt > now + 60_000) {
|
||||
return state.accessToken;
|
||||
}
|
||||
if (!state.refreshToken) {
|
||||
throw new Error("Not connected to Google Calendar.");
|
||||
}
|
||||
const refreshed = await refreshGoogleTokenViaAccounts(state.refreshToken);
|
||||
const expiresAt = refreshed.expires_in
|
||||
? Date.now() + refreshed.expires_in * 1000
|
||||
: Date.now() + 3_600_000;
|
||||
await writeGoogleCalendarState({
|
||||
accessToken: refreshed.access_token,
|
||||
refreshToken: refreshed.refresh_token ?? state.refreshToken,
|
||||
expiresAt,
|
||||
});
|
||||
return refreshed.access_token;
|
||||
}
|
||||
|
||||
async function connectGoogleCalendar(): Promise<GoogleCalendarSyncResult> {
|
||||
if (!isGoogleCalendarConfigured()) {
|
||||
return {
|
||||
success: false,
|
||||
configured: false,
|
||||
error: "Google Calendar is not configured in this extension build.",
|
||||
};
|
||||
}
|
||||
|
||||
const redirectUri = GOOGLE_CALENDAR_OAUTH_CALLBACK;
|
||||
const verifier = randomVerifier();
|
||||
const challenge = await pkceChallenge(verifier);
|
||||
const authUrl = new URL(GOOGLE_AUTH_URL);
|
||||
authUrl.searchParams.set("client_id", GOOGLE_OAUTH_CLIENT_ID);
|
||||
authUrl.searchParams.set("response_type", "code");
|
||||
authUrl.searchParams.set("redirect_uri", redirectUri);
|
||||
authUrl.searchParams.set("scope", GOOGLE_CALENDAR_SCOPE);
|
||||
authUrl.searchParams.set("access_type", "offline");
|
||||
authUrl.searchParams.set("prompt", "consent");
|
||||
authUrl.searchParams.set("code_challenge", challenge);
|
||||
authUrl.searchParams.set("code_challenge_method", "S256");
|
||||
|
||||
let responseUrl: string;
|
||||
try {
|
||||
responseUrl = await openGoogleOAuthTab(authUrl.toString());
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Google sign-in failed";
|
||||
const mismatch =
|
||||
/redirect_uri_mismatch|invalid_request/i.test(message) ||
|
||||
/redirect_uri_mismatch|invalid_request/i.test(String(err));
|
||||
return {
|
||||
success: false,
|
||||
configured: true,
|
||||
error: mismatch
|
||||
? `Google redirect URI mismatch. ${googleOAuthRedirectUriHint()}`
|
||||
: message.includes("cancel")
|
||||
? "Google sign-in was cancelled."
|
||||
: message,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const code = parseRedirectCode(responseUrl);
|
||||
const tokens = await exchangeGoogleCodeViaAccounts(code, redirectUri, verifier);
|
||||
const expiresAt = tokens.expires_in
|
||||
? Date.now() + tokens.expires_in * 1000
|
||||
: Date.now() + 3_600_000;
|
||||
|
||||
const existing = await readGoogleCalendarState();
|
||||
await writeGoogleCalendarState({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token ?? existing.refreshToken,
|
||||
expiresAt,
|
||||
connectedAt: Date.now(),
|
||||
});
|
||||
|
||||
await ensureWeeklySyncAlarm();
|
||||
|
||||
return { success: true, configured: true, connected: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
configured: true,
|
||||
error: err instanceof Error ? err.message : "Google sign-in failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function getGoogleCalendarStatus(): Promise<GoogleCalendarStatus> {
|
||||
const state = await readGoogleCalendarState();
|
||||
const shared = await readSharedCalendarSyncSettings();
|
||||
const syncWeeksAhead = await getSyncWeeksAhead();
|
||||
const autoSyncWeekly = await getAutoSyncWeekly();
|
||||
return {
|
||||
configured: isGoogleCalendarConfigured(),
|
||||
connected: !!(state.refreshToken || state.accessToken),
|
||||
lastSyncAt: state.lastSyncAt,
|
||||
lastWeeklySyncAt: shared.lastWeeklySyncAt,
|
||||
lastSyncOrigin: state.lastSyncOrigin,
|
||||
syncWeeksAhead,
|
||||
autoSyncWeekly,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleGoogleCalendarConnect(): Promise<GoogleCalendarSyncResult> {
|
||||
return connectGoogleCalendar();
|
||||
}
|
||||
|
||||
export async function handleGoogleCalendarDisconnect(): Promise<{ success: boolean }> {
|
||||
await clearGoogleCalendarState();
|
||||
await ensureWeeklySyncAlarm();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function handleGoogleCalendarStatus(): Promise<GoogleCalendarStatus> {
|
||||
return getGoogleCalendarStatus();
|
||||
}
|
||||
|
||||
export function registerGoogleCalendarMessageHandlers(
|
||||
handlers: Record<
|
||||
string,
|
||||
(
|
||||
request: unknown,
|
||||
sendResponse: (response?: unknown) => void,
|
||||
sender?: browser.Runtime.MessageSender,
|
||||
) => boolean | void
|
||||
>,
|
||||
isTrustedSender: (sender?: browser.Runtime.MessageSender) => boolean,
|
||||
): void {
|
||||
const rejectUntrusted = (
|
||||
sendResponse: (response?: unknown) => void,
|
||||
sender?: browser.Runtime.MessageSender,
|
||||
): boolean => {
|
||||
if (isTrustedSender(sender)) return false;
|
||||
sendResponse({ success: false, error: "Unauthorized sender" });
|
||||
return true;
|
||||
};
|
||||
|
||||
handlers.googleCalendarConnect = (_req, sendResponse, sender) => {
|
||||
if (rejectUntrusted(sendResponse, sender)) return false;
|
||||
void handleGoogleCalendarConnect()
|
||||
.then(sendResponse)
|
||||
.catch((err) => {
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Google sign-in failed",
|
||||
});
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
handlers.googleCalendarDisconnect = (_req, sendResponse, sender) => {
|
||||
if (rejectUntrusted(sendResponse, sender)) return false;
|
||||
void handleGoogleCalendarDisconnect()
|
||||
.then(sendResponse)
|
||||
.catch((err) => {
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Disconnect failed",
|
||||
});
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
handlers.googleCalendarStatus = (_req, sendResponse, sender) => {
|
||||
if (rejectUntrusted(sendResponse, sender)) return false;
|
||||
void handleGoogleCalendarStatus()
|
||||
.then(sendResponse)
|
||||
.catch(() => {
|
||||
sendResponse({ configured: isGoogleCalendarConfigured(), connected: false });
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
handlers.googleCalendarGetAccessToken = (_req, sendResponse, sender) => {
|
||||
if (rejectUntrusted(sendResponse, sender)) return false;
|
||||
void getValidAccessToken()
|
||||
.then((accessToken) => sendResponse({ success: true, accessToken }))
|
||||
.catch((err) => {
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Token refresh failed",
|
||||
});
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
handlers.googleCalendarEnsureWeeklyAlarm = (_req, sendResponse, sender) => {
|
||||
if (rejectUntrusted(sendResponse, sender)) return false;
|
||||
void ensureWeeklySyncAlarm()
|
||||
.then(() => sendResponse({ success: true }))
|
||||
.catch((err) => {
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Could not schedule weekly sync",
|
||||
});
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
handlers.googleCalendarUpdateSyncSettings = (request, sendResponse, sender) => {
|
||||
if (rejectUntrusted(sendResponse, sender)) return false;
|
||||
void (async () => {
|
||||
const body = request as {
|
||||
syncWeeksAhead?: number;
|
||||
autoSyncWeekly?: boolean;
|
||||
};
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (body.syncWeeksAhead != null) {
|
||||
patch.syncWeeksAhead = clampSyncWeeks(body.syncWeeksAhead);
|
||||
}
|
||||
if (body.autoSyncWeekly != null) {
|
||||
patch.autoSyncWeekly = !!body.autoSyncWeekly;
|
||||
}
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await writeSharedCalendarSyncSettings(patch);
|
||||
}
|
||||
await ensureWeeklySyncAlarm();
|
||||
sendResponse({ success: true, ...(await getGoogleCalendarStatus()) });
|
||||
})().catch((err) => {
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Could not update sync settings",
|
||||
});
|
||||
});
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export { initCalendarBackground as initGoogleCalendarBackground } from "./calendarWeekly";
|
||||
@@ -1,302 +0,0 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import {
|
||||
OUTLOOK_AUTH_URL,
|
||||
OUTLOOK_CALENDAR_OAUTH_CALLBACK,
|
||||
OUTLOOK_CALENDAR_SCOPE,
|
||||
OUTLOOK_OAUTH_CLIENT_ID,
|
||||
isOutlookCalendarConfigured,
|
||||
outlookOAuthRedirectUriHint,
|
||||
} from "@/config/outlookCalendar";
|
||||
import {
|
||||
exchangeOutlookCodeViaAccounts,
|
||||
refreshOutlookTokenViaAccounts,
|
||||
} from "@/seqta/utils/outlookCalendar/accountsToken";
|
||||
import {
|
||||
clearOutlookCalendarState,
|
||||
readOutlookCalendarState,
|
||||
writeOutlookCalendarState,
|
||||
} from "@/seqta/utils/outlookCalendar/storage";
|
||||
import type { OutlookCalendarStatus } from "@/seqta/utils/outlookCalendar/types";
|
||||
import type { GoogleCalendarSyncResult } from "@/seqta/utils/googleCalendar/types";
|
||||
import { ensureWeeklySyncAlarm } from "./calendarWeekly";
|
||||
|
||||
function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (const b of bytes) binary += String.fromCharCode(b);
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
async function sha256(input: string): Promise<ArrayBuffer> {
|
||||
const data = new TextEncoder().encode(input);
|
||||
return crypto.subtle.digest("SHA-256", data);
|
||||
}
|
||||
|
||||
function randomVerifier(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return base64UrlEncode(bytes.buffer);
|
||||
}
|
||||
|
||||
async function pkceChallenge(verifier: string): Promise<string> {
|
||||
return base64UrlEncode(await sha256(verifier));
|
||||
}
|
||||
|
||||
function parseRedirectCode(responseUrl: string): string {
|
||||
const url = new URL(responseUrl);
|
||||
const err = url.searchParams.get("error");
|
||||
if (err) throw new Error(url.searchParams.get("error_description") ?? err);
|
||||
const code = url.searchParams.get("code");
|
||||
if (!code) throw new Error("Microsoft sign-in did not return an authorization code.");
|
||||
return code;
|
||||
}
|
||||
|
||||
const OAUTH_CALLBACK_PREFIX = OUTLOOK_CALENDAR_OAUTH_CALLBACK;
|
||||
const OAUTH_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
function isOAuthCallbackUrl(url: string): boolean {
|
||||
if (!url.startsWith(OAUTH_CALLBACK_PREFIX)) return false;
|
||||
const parsed = new URL(url);
|
||||
return parsed.searchParams.has("code") || parsed.searchParams.has("error");
|
||||
}
|
||||
|
||||
function waitForOAuthCallback(authTabId: number): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Microsoft sign-in timed out. Close the tab and try again."));
|
||||
}, OAUTH_TIMEOUT_MS);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeoutId);
|
||||
browser.tabs.onUpdated.removeListener(onUpdated);
|
||||
browser.tabs.onRemoved.removeListener(onRemoved);
|
||||
};
|
||||
|
||||
const finishFromUrl = (url: string, tabId: number) => {
|
||||
if (!isOAuthCallbackUrl(url)) return false;
|
||||
cleanup();
|
||||
void browser.tabs.remove(tabId).catch(() => {});
|
||||
resolve(url);
|
||||
return true;
|
||||
};
|
||||
|
||||
const onRemoved = (tabId: number) => {
|
||||
if (tabId !== authTabId) return;
|
||||
cleanup();
|
||||
reject(new Error("Microsoft sign-in was cancelled."));
|
||||
};
|
||||
|
||||
const onUpdated = (
|
||||
tabId: number,
|
||||
changeInfo: browser.Tabs.OnUpdatedChangeInfoType,
|
||||
tab: browser.Tabs.Tab,
|
||||
) => {
|
||||
if (tabId !== authTabId) return;
|
||||
const url =
|
||||
changeInfo.url ?? (changeInfo.status === "complete" ? tab.url : undefined);
|
||||
if (url) finishFromUrl(url, tabId);
|
||||
};
|
||||
|
||||
browser.tabs.onUpdated.addListener(onUpdated);
|
||||
browser.tabs.onRemoved.addListener(onRemoved);
|
||||
|
||||
void browser.tabs
|
||||
.get(authTabId)
|
||||
.then((tab) => {
|
||||
if (tab.url) finishFromUrl(tab.url, authTabId);
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
async function openOAuthTab(authUrl: string): Promise<string> {
|
||||
const tab = await browser.tabs.create({ url: authUrl, active: true });
|
||||
if (tab.id === undefined) {
|
||||
throw new Error("Could not open Microsoft sign-in tab.");
|
||||
}
|
||||
return waitForOAuthCallback(tab.id);
|
||||
}
|
||||
|
||||
async function getValidAccessToken(): Promise<string> {
|
||||
const state = await readOutlookCalendarState();
|
||||
const now = Date.now();
|
||||
if (state.accessToken && state.expiresAt && state.expiresAt > now + 60_000) {
|
||||
return state.accessToken;
|
||||
}
|
||||
if (!state.refreshToken) {
|
||||
throw new Error("Not connected to Outlook Calendar.");
|
||||
}
|
||||
const refreshed = await refreshOutlookTokenViaAccounts(state.refreshToken);
|
||||
const expiresAt = refreshed.expires_in
|
||||
? Date.now() + refreshed.expires_in * 1000
|
||||
: Date.now() + 3_600_000;
|
||||
await writeOutlookCalendarState({
|
||||
accessToken: refreshed.access_token,
|
||||
refreshToken: refreshed.refresh_token ?? state.refreshToken,
|
||||
expiresAt,
|
||||
});
|
||||
return refreshed.access_token;
|
||||
}
|
||||
|
||||
async function connectOutlookCalendar(): Promise<GoogleCalendarSyncResult> {
|
||||
if (!isOutlookCalendarConfigured()) {
|
||||
return {
|
||||
success: false,
|
||||
configured: false,
|
||||
error: "Outlook Calendar is not configured in this extension build.",
|
||||
};
|
||||
}
|
||||
|
||||
const redirectUri = OUTLOOK_CALENDAR_OAUTH_CALLBACK;
|
||||
const verifier = randomVerifier();
|
||||
const challenge = await pkceChallenge(verifier);
|
||||
const authUrl = new URL(OUTLOOK_AUTH_URL);
|
||||
authUrl.searchParams.set("client_id", OUTLOOK_OAUTH_CLIENT_ID);
|
||||
authUrl.searchParams.set("response_type", "code");
|
||||
authUrl.searchParams.set("redirect_uri", redirectUri);
|
||||
authUrl.searchParams.set("scope", OUTLOOK_CALENDAR_SCOPE);
|
||||
authUrl.searchParams.set("response_mode", "query");
|
||||
authUrl.searchParams.set("prompt", "consent");
|
||||
authUrl.searchParams.set("code_challenge", challenge);
|
||||
authUrl.searchParams.set("code_challenge_method", "S256");
|
||||
|
||||
let responseUrl: string;
|
||||
try {
|
||||
responseUrl = await openOAuthTab(authUrl.toString());
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Microsoft sign-in failed";
|
||||
const mismatch =
|
||||
/redirect_uri|invalid_request|AADSTS50011/i.test(message) ||
|
||||
/redirect_uri|invalid_request|AADSTS50011/i.test(String(err));
|
||||
return {
|
||||
success: false,
|
||||
configured: true,
|
||||
error: mismatch
|
||||
? `Microsoft redirect URI mismatch. ${outlookOAuthRedirectUriHint()}`
|
||||
: message.includes("cancel")
|
||||
? "Microsoft sign-in was cancelled."
|
||||
: message,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const code = parseRedirectCode(responseUrl);
|
||||
const tokens = await exchangeOutlookCodeViaAccounts(code, redirectUri, verifier);
|
||||
const expiresAt = tokens.expires_in
|
||||
? Date.now() + tokens.expires_in * 1000
|
||||
: Date.now() + 3_600_000;
|
||||
|
||||
const existing = await readOutlookCalendarState();
|
||||
await writeOutlookCalendarState({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token ?? existing.refreshToken,
|
||||
expiresAt,
|
||||
connectedAt: Date.now(),
|
||||
});
|
||||
|
||||
await ensureWeeklySyncAlarm();
|
||||
|
||||
return { success: true, configured: true, connected: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
configured: true,
|
||||
error: err instanceof Error ? err.message : "Microsoft sign-in failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function getOutlookCalendarStatus(): Promise<OutlookCalendarStatus> {
|
||||
const state = await readOutlookCalendarState();
|
||||
return {
|
||||
configured: isOutlookCalendarConfigured(),
|
||||
connected: !!(state.refreshToken || state.accessToken),
|
||||
lastSyncAt: state.lastSyncAt,
|
||||
lastSyncOrigin: state.lastSyncOrigin,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleOutlookCalendarConnect(): Promise<GoogleCalendarSyncResult> {
|
||||
return connectOutlookCalendar();
|
||||
}
|
||||
|
||||
export async function handleOutlookCalendarDisconnect(): Promise<{ success: boolean }> {
|
||||
await clearOutlookCalendarState();
|
||||
await ensureWeeklySyncAlarm();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function handleOutlookCalendarStatus(): Promise<OutlookCalendarStatus> {
|
||||
return getOutlookCalendarStatus();
|
||||
}
|
||||
|
||||
export function registerOutlookCalendarMessageHandlers(
|
||||
handlers: Record<
|
||||
string,
|
||||
(
|
||||
request: unknown,
|
||||
sendResponse: (response?: unknown) => void,
|
||||
sender?: browser.Runtime.MessageSender,
|
||||
) => boolean | void
|
||||
>,
|
||||
isTrustedSender: (sender?: browser.Runtime.MessageSender) => boolean,
|
||||
): void {
|
||||
const rejectUntrusted = (
|
||||
sendResponse: (response?: unknown) => void,
|
||||
sender?: browser.Runtime.MessageSender,
|
||||
): boolean => {
|
||||
if (isTrustedSender(sender)) return false;
|
||||
sendResponse({ success: false, error: "Unauthorized sender" });
|
||||
return true;
|
||||
};
|
||||
|
||||
handlers.outlookCalendarConnect = (_req, sendResponse, sender) => {
|
||||
if (rejectUntrusted(sendResponse, sender)) return false;
|
||||
void handleOutlookCalendarConnect()
|
||||
.then(sendResponse)
|
||||
.catch((err) => {
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Microsoft sign-in failed",
|
||||
});
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
handlers.outlookCalendarDisconnect = (_req, sendResponse, sender) => {
|
||||
if (rejectUntrusted(sendResponse, sender)) return false;
|
||||
void handleOutlookCalendarDisconnect()
|
||||
.then(sendResponse)
|
||||
.catch((err) => {
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Disconnect failed",
|
||||
});
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
handlers.outlookCalendarStatus = (_req, sendResponse, sender) => {
|
||||
if (rejectUntrusted(sendResponse, sender)) return false;
|
||||
void handleOutlookCalendarStatus()
|
||||
.then(sendResponse)
|
||||
.catch(() => {
|
||||
sendResponse({ configured: isOutlookCalendarConfigured(), connected: false });
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
handlers.outlookCalendarGetAccessToken = (_req, sendResponse, sender) => {
|
||||
if (rejectUntrusted(sendResponse, sender)) return false;
|
||||
void getValidAccessToken()
|
||||
.then((accessToken) => sendResponse({ success: true, accessToken }))
|
||||
.catch((err) => {
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Token refresh failed",
|
||||
});
|
||||
});
|
||||
return true;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user