Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"sideEffects": false,
"devDependencies": {
"@types/chrome": "^0.0.268",
"@types/firefox-webext-browser": "^143.0.0",
"@types/jest": "^29.5.12",
"@types/jest-environment-puppeteer": "^5.0.6",
"@types/puppeteer": "^5.4.7",
Expand Down
95 changes: 30 additions & 65 deletions scripts/package.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,17 @@ async function packageExtension(browserName = 'chromium') {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const version = packageJson.version;

// Create release directory structure
const releaseDir = path.join(__dirname, '..', 'release');
const versionDir = path.join(releaseDir, browserName, version);
const packagePath = path.join(versionDir, `level-up-vnext-${browserName}-v${version}.zip`);

// Modify manifest for Firefox if needed
if (browserName === 'firefox') {
modifyManifestForFirefox(buildDir);
}
// Create merged manifest for the specific browser
await createMergedManifest(buildDir, browserName);

// Validate build directory contains only compiled assets
validateBuildDirectory(buildDir, browserName);
validateBuildDirectory(buildDir);

// Create release directories
const releaseDir = path.join(__dirname, '..', 'release');
const versionDir = path.join(releaseDir, browserName, version);
const packagePath = path.join(versionDir, `level-up-vnext-${browserName}-v${version}.zip`);

if (!fs.existsSync(releaseDir)) {
fs.mkdirSync(releaseDir, { recursive: true });
}
Expand Down Expand Up @@ -75,11 +72,8 @@ async function packageExtension(browserName = 'chromium') {
// Pipe archive data to the file
archive.pipe(output);

// Add files from build directory, excluding sourcemap files and sidebar files for Firefox
// Add files from build directory, excluding sourcemap files
const ignorePatterns = ['**/*.map'];
if (browserName === 'firefox') {
ignorePatterns.push('sidebar.*');
}
archive.glob('**/*', {
cwd: buildDir,
ignore: ignorePatterns,
Expand All @@ -90,7 +84,7 @@ async function packageExtension(browserName = 'chromium') {
});
}

function validateBuildDirectory(buildDir, browserName) {
function validateBuildDirectory(buildDir) {
const files = getAllFiles(buildDir);
const invalidFiles = files.filter(file => {
const ext = path.extname(file).toLowerCase();
Expand All @@ -114,12 +108,11 @@ function validateBuildDirectory(buildDir, browserName) {
'levelup-extension.js',
'popup.html',
'popup.js',
'sidebar.js',
'sidebar.html',
'sidebar.css',
];

if (browserName === 'chromium') {
requiredFiles.push('sidebar.js', 'sidebar.html', 'sidebar.css');
}

const missingFiles = requiredFiles.filter(file => !fs.existsSync(path.join(buildDir, file)));

if (missingFiles.length > 0) {
Expand Down Expand Up @@ -151,56 +144,28 @@ function getAllFiles(dir) {
return files;
}

function modifyManifestForFirefox(buildDir) {
const manifestPath = path.join(buildDir, 'manifest.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));

// Remove sidePanel permission (Firefox doesn't support sidePanel API)
if (manifest.permissions && manifest.permissions.includes('sidePanel')) {
manifest.permissions = manifest.permissions.filter(p => p !== 'sidePanel');
}

// Remove declarativeNetRequest for Firefox (different implementation)
if (manifest.permissions && manifest.permissions.includes('declarativeNetRequest')) {
manifest.permissions = manifest.permissions.filter(p => p !== 'declarativeNetRequest');
}

// Remove contextMenus for Firefox (Firefox uses 'menus' API instead)
if (manifest.permissions && manifest.permissions.includes('contextMenus')) {
manifest.permissions = manifest.permissions.filter(p => p !== 'contextMenus');
}

// Remove side_panel (Firefox doesn't support this)
delete manifest.side_panel;

// Remove sidebar_action (Firefox uses different sidebar implementation)
delete manifest.sidebar_action;
function createMergedManifest(buildDir, browserName) {
const srcDir = path.join(__dirname, '..', 'src');

// Update web_accessible_resources to remove sidebar.html
if (manifest.web_accessible_resources) {
manifest.web_accessible_resources.forEach(resource => {
if (resource.resources) {
resource.resources = resource.resources.filter(r => r !== 'sidebar.html');
}
});
}
// Read the three manifest files
const commonManifest = JSON.parse(
fs.readFileSync(path.join(srcDir, 'manifest_common.json'), 'utf8')
);
const browserManifest = JSON.parse(
fs.readFileSync(path.join(srcDir, `manifest_${browserName}.json`), 'utf8')
);

// Fix background for Firefox (Firefox doesn't support service_worker in same way)
if (manifest.background) {
delete manifest.background.service_worker;
manifest.background.scripts = ['background.js'];
}
// Merge the manifests (browser-specific properties override common ones)
const mergedManifest = {
...commonManifest,
...browserManifest,
};

// Change host_permissions to optional_host_permissions for Firefox
if (manifest.host_permissions) {
manifest.optional_host_permissions = manifest.host_permissions;
delete manifest.host_permissions;
}
// Write the merged manifest to build directory
const manifestPath = path.join(buildDir, 'manifest.json');
fs.writeFileSync(manifestPath, JSON.stringify(mergedManifest, null, 2));

fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
console.log(
'✅ Manifest modified for Firefox (removed sidePanel, declarativeNetRequest, contextMenus)'
);
console.log(`✅ Created merged manifest for ${browserName}`);
}

// Create proper icon files (SVG converted to simple format for development)
Expand Down
176 changes: 176 additions & 0 deletions src/background/adapters/chrome.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { BrowserAdapter } from '.';

export class ChromeAdapter implements BrowserAdapter {
name = 'Chrome';
scripting = chrome.scripting;
runtime = chrome.runtime;
tabs = chrome.tabs;
action = chrome.action;
declarativeNetRequest = chrome.declarativeNetRequest;

async openSidebar(tabId?: number): Promise<void> {
if (!tabId) {
throw new Error('Tab ID is required for Chrome side panel');
}
await chrome.sidePanel.open({ tabId });
}

async updateSidePanelForTab(
tabId: number,
url?: string | null,
options?: { openIfDynamics?: boolean; force?: boolean },
userClosedPanelTabs?: Set<number>
): Promise<void> {
// For navigation events, check if it's Dynamics and inject content script if needed
const isDynamicsPage = await this.isDynamics365Page(tabId);

if (url) {
// Always inject content script so sidebar can communicate
// The content script will determine internally if it should activate features
try {
const isLoaded = await this.checkContentScriptLoaded(tabId);
if (!isLoaded) {
try {
await this.scripting.executeScript({
target: { tabId },
files: ['content.js'],
});
console.log(
`🔍 [UpdatePanel] Content script injected for sidebar communication on tab ${tabId}`
);
} catch (error) {
console.log(
`🔍 [UpdatePanel] Content script injection failed for tab ${tabId}:`,
error
);
}
} else {
console.log(`🔍 [UpdatePanel] Content script already loaded for tab ${tabId}`);
}
} catch (error) {
console.log(`🔍 [UpdatePanel] Script injection failed for tab ${tabId}:`, error);
}
}

// Always keep side panel enabled so user sees an informational message on non-Dynamics tabs
try {
await chrome.sidePanel.setOptions({
tabId,
path: 'sidebar.html',
enabled: true,
});
} catch (e) {
console.log('[Background] Failed to set side panel options:', e);
}

// Auto-open only for Dynamics tabs (previous behavior) unless user previously closed it
if (
isDynamicsPage &&
(options?.openIfDynamics || options?.force) &&
(options?.force || !userClosedPanelTabs?.has(tabId))
) {
try {
await chrome.sidePanel.open({ tabId });
} catch (e) {
console.log('[Background] Failed to open side panel:', e);
}
} else {
console.log(
`🔍 [UpdatePanel] Not auto-opening side panel for tab ${tabId}. Dynamics: ${isDynamicsPage}, openIfDynamics: ${options?.openIfDynamics}, force: ${options?.force}, userClosed: ${userClosedPanelTabs?.has(tabId)}`
);
}
}

private async checkContentScriptLoaded(tabId: number): Promise<boolean> {
try {
const results = await this.scripting.executeScript({
target: { tabId },
func: () => {
return !!window.__levelUpContentScriptLoaded;
},
});
return results && results[0] && results[0].result === true;
} catch (error) {
// If script execution fails, assume content script is not loaded
return false;
}
}

private async isDynamics365Page(tabId: number): Promise<boolean> {
try {
const results = await this.scripting.executeScript({
target: { tabId },
func: () => {
// Method 1: Check for Xrm.Utility.getGlobalContext()
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const win = window as any;
if (typeof window !== 'undefined' && win.Xrm?.Utility?.getGlobalContext) {
const version = win.Xrm.Utility.getGlobalContext().getVersion();
if (version && version.startsWith('9.')) {
return true;
}
}
} catch (error) {
// Continue with other checks if Xrm check fails
}

// Method 2: Check for Dynamics 365 specific script tags
try {
const scripts = Array.from(document.querySelectorAll('script[src]'));
const hasDynamicsScript = scripts.some(script => {
const src = (script as HTMLScriptElement).src;
return (
src.indexOf('/uclient/scripts') !== -1 ||
src.indexOf('/_static/_common/scripts/PageLoader.js') !== -1 ||
src.indexOf('/_static/_common/scripts/crminternalutility.js') !== -1
);
});

if (hasDynamicsScript) {
return true;
}
} catch (error) {
// Continue if script detection fails
}

return false;
},
});

return results && results[0] && results[0].result === true;
} catch (error) {
// If script execution fails, assume it's not a Dynamics page
return false;
}
}

setupContextMenu(): void {
const contextMenusAPI = chrome.contextMenus;

contextMenusAPI.removeAll(() => {
contextMenusAPI.create({
id: 'levelup-open',
title: 'Open Level Up Sidebar',
contexts: ['page'],
});
});

contextMenusAPI.onClicked.addListener(
async (info: chrome.contextMenus.OnClickData, tab?: chrome.tabs.Tab) => {
if (info.menuItemId === 'levelup-open' && tab?.id) {
try {
await this.openSidebar(tab.id);
console.log(' [ContextMenu] Opened Chrome side panel via context menu');
} catch (error) {
console.log(' [ContextMenu] Failed to open sidebar:', error);
}
}
}
);
}

sendMessage(message: unknown): Promise<void> {
return this.runtime.sendMessage(message);
}
}
Loading