diff --git a/src/cli/builders/manifest/ManifestBase.test.ts b/src/cli/builders/manifest/ManifestBase.test.ts index 85e7b74..204542c 100644 --- a/src/cli/builders/manifest/ManifestBase.test.ts +++ b/src/cli/builders/manifest/ManifestBase.test.ts @@ -1,6 +1,502 @@ import ManifestV3 from "./ManifestV3"; +import ManifestV2 from "./ManifestV2"; import {Browser, DataCollectionPermission} from "@typing/browser"; +const unique = (arr: string[]) => Array.from(new Set(arr)).length === arr.length; + +describe("ManifestBase primitive properties", () => { + it("name", () => { + const builder1 = new ManifestV3(Browser.Chrome); + builder1.setName("InternalName"); + builder1.raw({name: "OptionalName"}); + const manifest1: any = builder1.build(); + expect(manifest1.name).toBe("InternalName"); + + const builder2 = new ManifestV3(Browser.Chrome); + builder2.raw({name: "OptionalName"}); + const manifest2: any = builder2.build(); + expect(manifest2.name).toBe("OptionalName"); + + const builder3 = new ManifestV3(Browser.Chrome); + const manifest3: any = builder3.build(); + expect(manifest3.name).toBe("__MSG_app_name__"); + }); + + it("short_name", () => { + const builder1 = new ManifestV3(Browser.Chrome); + builder1.setShortName("Short"); + builder1.raw({short_name: "OptShort"}); + const manifest1: any = builder1.build(); + expect(manifest1.short_name).toBe("Short"); + + const builder2 = new ManifestV3(Browser.Chrome); + builder2.raw({short_name: "OptShort"}); + const manifest2: any = builder2.build(); + expect(manifest2.short_name).toBe("OptShort"); + + const builder3 = new ManifestV3(Browser.Chrome); + const manifest3: any = builder3.build(); + expect(manifest3.short_name).toBeUndefined(); + }); + + it("description", () => { + const builder1 = new ManifestV3(Browser.Chrome); + builder1.setDescription("Desc"); + builder1.raw({description: "OptDesc"}); + const manifest1: any = builder1.build(); + expect(manifest1.description).toBe("Desc"); + + const builder2 = new ManifestV3(Browser.Chrome); + builder2.raw({description: "OptDesc"}); + const manifest2: any = builder2.build(); + expect(manifest2.description).toBe("OptDesc"); + + const builder3 = new ManifestV3(Browser.Chrome); + const manifest3: any = builder3.build(); + expect(manifest3.description).toBeUndefined(); + }); + + it("version", () => { + const builder1 = new ManifestV3(Browser.Chrome); + builder1.setVersion("1.2.3"); + builder1.raw({version: "9.9.9"}); + const manifest1: any = builder1.build(); + expect(manifest1.version).toBe("1.2.3"); + + const builder2 = new ManifestV3(Browser.Chrome); + builder2.raw({version: "9.9.9"}); + const manifest2: any = builder2.build(); + expect(manifest2.version).toBe("9.9.9"); + + const builder3 = new ManifestV3(Browser.Chrome); + const manifest3: any = builder3.build(); + expect(manifest3.version).toBe("0.0.0"); + }); + + it("minimum_chrome_version", () => { + const builder1 = new ManifestV3(Browser.Chrome); + builder1.setMinimumVersion("120.0.0"); + builder1.raw({minimum_chrome_version: "100.0.0"}); + const manifest1: any = builder1.build(); + expect(manifest1.minimum_chrome_version).toBe("120.0.0"); + + const builder2 = new ManifestV3(Browser.Chrome); + builder2.raw({minimum_chrome_version: "100.0.0"}); + const manifest2: any = builder2.build(); + expect(manifest2.minimum_chrome_version).toBe("100.0.0"); + + const builder3 = new ManifestV3(Browser.Chrome); + const manifest3: any = builder3.build(); + expect(manifest3.minimum_chrome_version).toBeUndefined(); + }); + + it("author", () => { + const builder1 = new ManifestV3(Browser.Chrome); + builder1.setAuthor("AddonBone"); + const manifest1: any = builder1.build(); + expect(manifest1.author).toBe("AddonBone"); + + const builder2 = new ManifestV3(Browser.Chrome); + builder2.raw({author: "Opt"}); + const manifest2: any = builder2.build(); + expect(manifest2.author).toBe("Opt"); + + const builder3 = new ManifestV3(Browser.Chrome); + const manifest3: any = builder3.build(); + expect(manifest3.author).toBeUndefined(); + }); + + it("homepage_url", () => { + const builder1 = new ManifestV3(Browser.Chrome); + builder1.setHomepage("https://me.example"); + const manifest1: any = builder1.build(); + expect(manifest1.homepage_url).toBe("https://me.example"); + + const builder2 = new ManifestV3(Browser.Chrome); + builder2.raw({homepage_url: "https://opt.example"}); + const manifest2: any = builder2.build(); + expect(manifest2.homepage_url).toBe("https://opt.example"); + + const builder3 = new ManifestV3(Browser.Chrome); + const manifest3: any = builder3.build(); + expect(manifest3.homepage_url).toBeUndefined(); + }); + + it("incognito", () => { + const builder1 = new ManifestV3(Browser.Chrome); + builder1.setIncognito("not_allowed" as any); + const manifest1: any = builder1.build(); + expect(manifest1.incognito).toBe("not_allowed"); + + const builder2 = new ManifestV3(Browser.Chrome); + builder2.raw({incognito: "split"}); + const manifest2: any = builder2.build(); + expect(manifest2.incognito).toBe("split"); + + const builder3 = new ManifestV3(Browser.Chrome); + const manifest3: any = builder3.build(); + expect(manifest3.incognito).toBeUndefined(); + }); + + it("default_locale", () => { + const builder1 = new ManifestV3(Browser.Chrome); + builder1.setLocale("en" as any); + const manifest1: any = builder1.build(); + expect(manifest1.default_locale).toBe("en"); + + const builder2 = new ManifestV3(Browser.Chrome); + builder2.raw({default_locale: "uk"}); + const manifest2: any = builder2.build(); + expect(manifest2.default_locale).toBe("uk"); + + const builder3 = new ManifestV3(Browser.Chrome); + const manifest3: any = builder3.build(); + expect(manifest3.default_locale).toBeUndefined(); + }); +}); + +describe("ManifestBase merged properties", () => { + it("merging objects and arrays", () => { + const builder = new ManifestV3(Browser.Chrome); + + builder + .raw({permissions: ["tabs"]}) + .raw({permissions: ["storage"]}) + .raw({commands: {cmd1: {description: "First"}}}) + .raw({commands: {cmd2: {description: "Second"}}}); + + const manifest: any = builder.build(); + + expect(manifest.permissions).toEqual(expect.arrayContaining(["tabs", "storage"])); + expect(manifest.commands).toEqual( + expect.objectContaining({ + cmd1: {description: "First"}, + cmd2: {description: "Second"}, + }) + ); + }); + + it("commands", () => { + const builder = new ManifestV3(Browser.Chrome); + + builder.setCommands( + new Set([ + {name: "internal_command"}, + { + name: "common", + description: "Internal description", + chromeosKey: "Internal chromeosKey", + }, + ]) + ); + + builder.raw({ + commands: { + raw_command: {}, + common: { + description: "Raw description", + suggested_key: { + mac: "Raw macKey", + }, + }, + }, + }); + const commands: any = builder.build().commands; + + expect(commands.raw_command).toBeDefined(); + expect(commands.internal_command).toBeDefined(); + expect(commands.common.description).toBe("Internal description"); + expect(commands.common.suggested_key.chromeos).toBe("Internal chromeosKey"); + expect(commands.common.suggested_key.mac).toBe("Raw macKey"); + }); + + it("content_scripts", () => { + const builder = new ManifestV3(Browser.Chrome); + + builder + .setDependencies( + new Map([ + [ + "entry", + { + js: new Set(["entry.js"]), + css: new Set(["entry.css"]), + assets: new Set(["entry.png"]), + }, + ], + ]) + ) + .setContentScripts( + new Set([ + { + matches: ["https://internal.com/*"], + entry: "entry", + }, + ]) + ); + + builder.raw({ + content_scripts: [ + { + matches: ["https://raw.com/*"], + js: ["raw.js"], + css: ["raw.css"], + }, + ], + }); + + const contentScripts: any = builder.build().content_scripts; + + expect(contentScripts).toBeDefined(); + expect(contentScripts.length).toBe(2); + }); + + it("icons", () => { + const builder = new ManifestV3(Browser.Chrome); + + builder.setIcons( + new Map([ + [ + "default", + new Map([ + [16, "internal16.png"], + [24, "internal24.png"], + ]), + ], + ]) + ); + + builder.raw({icons: {16: "raw16.png", 32: "raw32.png"}}); + + const icons: any = builder.build().icons; + + expect(icons["16"]).toBe("internal16.png"); + expect(icons["24"]).toBe("internal24.png"); + expect(icons["32"]).toBe("raw32.png"); + }); + + it("permissions", () => { + const builder_v3 = new ManifestV3(Browser.Chrome); + builder_v3.appendPermissions(new Set(["storage", "activeTab"])).raw({ + permissions: ["tabs"], + host_permissions: ["https://api.example.com/*"], + }); + const manifest_v3: any = builder_v3.build(); + expect(manifest_v3.host_permissions).toEqual(expect.arrayContaining(["https://api.example.com/*"])); + expect(manifest_v3.permissions).toEqual(expect.arrayContaining(["storage", "tabs"])); + + const builder_v2 = new ManifestV2(Browser.Chrome); + builder_v2 + .addPermission("storage") + .addHostPermission("https://*.example.com/*") + .raw({ + permissions: ["tabs", "activeTab"], + host_permissions: ["https://api.example.com/*"], + }); + const manifest_v2: any = builder_v2.build(); + expect(manifest_v2.host_permissions).toBeUndefined(); + expect(manifest_v2.permissions).toEqual( + expect.arrayContaining(["storage", "tabs", "https://*.example.com/*", "https://api.example.com/*"]) + ); + }); + + it("optional_permissions", () => { + const builder_v3 = new ManifestV3(Browser.Chrome); + builder_v3 + .addPermission("storage") + .addOptionalPermission("bookmarks") + .raw({optional_permissions: ["history", "storage"]}); + const manifest_v3: any = builder_v3.build(); + expect(manifest_v3.optional_permissions).toEqual(expect.arrayContaining(["bookmarks", "history"])); + expect(manifest_v3.optional_permissions).not.toEqual(expect.arrayContaining(["storage"])); + + // MV2: optional_permissions also include optional host permissions not already in host permissions + const builder_v2 = new ManifestV2(Browser.Chrome); + builder_v2 + .addPermission("storage") + .addHostPermission("https://*.example.com/*") + .setOptionalPermissions(new Set(["bookmarks"])) + .setOptionalHostPermissions(new Set(["https://opt.example.com/*", "https://*.example.com/*"])) + .raw({optional_permissions: ["history"]}); + const manifest_v2: any = builder_v2.build(); + expect(manifest_v2.optional_permissions).toEqual( + expect.arrayContaining(["bookmarks", "history", "https://opt.example.com/*"]) + ); + expect(manifest_v2.optional_permissions).not.toEqual(expect.arrayContaining(["https://*.example.com/*"])); + }); + + it("host_permissions", () => { + const builder_v3 = new ManifestV3(Browser.Chrome); + builder_v3.addHostPermission("https://*.example.com/*").raw({host_permissions: ["https://api.example.com/*"]}); + const manifest_v3: any = builder_v3.build(); + expect(manifest_v3.host_permissions).toEqual( + expect.arrayContaining(["https://*.example.com/*", "https://api.example.com/*"]) + ); + + const builder_v2 = new ManifestV2(Browser.Chrome); + builder_v2.addHostPermission("https://*.example.com/*").raw({host_permissions: ["https://api.example.com/*"]}); + const manifest_v2: any = builder_v2.build(); + expect(manifest_v2.host_permissions).toBeUndefined(); + expect(manifest_v2.permissions).toEqual( + expect.arrayContaining(["https://*.example.com/*", "https://api.example.com/*"]) + ); + }); + + it("optional_host_permissions", () => { + const builder_v3 = new ManifestV3(Browser.Chrome); + builder_v3 + .addHostPermission("https://*.example.com/*") + .setOptionalHostPermissions(new Set(["https://opt.example.com/*", "https://*.example.com/*"])) // duplicated one should be filtered out + .raw({optional_host_permissions: ["https://raw-opt.example.com/*"]}); + const manifest_v3: any = builder_v3.build(); + expect(manifest_v3.optional_host_permissions).toEqual( + expect.arrayContaining(["https://opt.example.com/*", "https://raw-opt.example.com/*"]) + ); + expect(manifest_v3.optional_host_permissions).not.toEqual(expect.arrayContaining(["https://*.example.com/*"])); + + const builder_v2 = new ManifestV2(Browser.Chrome); + builder_v2 + .addHostPermission("https://*.example.com/*") + .setOptionalHostPermissions(new Set(["https://opt.example.com/*"])); + const manifest_v2: any = builder_v2.build(); + expect(manifest_v2.optional_host_permissions).toBeUndefined(); + expect(manifest_v2.optional_permissions).toEqual(expect.arrayContaining(["https://opt.example.com/*"])); + }); + + it("web_accessible_resources (MV3)", () => { + const builder = new ManifestV3(Browser.Chrome); + + builder + .setDependencies( + new Map([ + [ + "entry", + { + js: new Set(["entry.js"]), + css: new Set(), + assets: new Set(["img/a.png", "img/b.png"]), + }, + ], + [ + "entry2", + { + js: new Set(["entry2.js"]), + css: new Set(), + assets: new Set(["img/b.png", "img/c.png"]), + }, + ], + ]) + ) + .setContentScripts( + new Set([ + { + matches: ["https://site.com/*"], + entry: "entry", + }, + { + matches: ["https://other.com/*"], + entry: "entry2", + }, + ]) + ) + .addAccessibleResource({resources: ["img/common.png"], matches: ["https://site.com/*"]}) + .raw({ + web_accessible_resources: [ + {resources: ["img/raw.png", "img/a.png"], matches: ["https://site.com/*"]}, + {resources: ["img/onlyraw.png"], matches: ["https://other.com/*"]}, + ], + }); + + const resources: any[] = builder.build().web_accessible_resources as any[]; + expect(Array.isArray(resources)).toBe(true); + + const byMatches = (pattern: string) => resources.find(r => (r.matches || []).includes(pattern)); + + const site = byMatches("https://site.com/*"); + expect(site).toBeDefined(); + expect(site.resources).toEqual( + expect.arrayContaining([ + "img/a.png", // from deps + raw + "img/b.png", // from deps + "img/common.png", // internal + "img/raw.png", // raw only + ]) + ); + + const other = byMatches("https://other.com/*"); + expect(other).toBeDefined(); + expect(other.resources).toEqual( + expect.arrayContaining([ + "img/b.png", // from entry2 deps + "img/c.png", // from entry2 deps + "img/onlyraw.png", // raw only + ]) + ); + + // Ensure no duplicates overall within each group + expect(unique(site.resources)).toBe(true); + expect(unique(other.resources)).toBe(true); + }); + + it("web_accessible_resources (MV2)", () => { + const builder = new ManifestV2(Browser.Chrome); + + builder + .setDependencies( + new Map([ + [ + "entry", + { + js: new Set(["entry.js"]), + css: new Set(), + assets: new Set(["img/a.png", "img/b.png"]), + }, + ], + [ + "entry2", + { + js: new Set(["entry2.js"]), + css: new Set(), + assets: new Set(["img/b.png", "img/c.png"]), + }, + ], + ]) + ) + .setContentScripts( + new Set([ + {matches: ["https://site.com/*"], entry: "entry"}, + {matches: ["https://other.com/*"], entry: "entry2"}, + ]) + ) + .addAccessibleResource({resources: ["img/common.png"], matches: ["https://site.com/*"]}) + .raw({ + web_accessible_resources: [ + {resources: ["img/raw.png", "img/a.png"], matches: ["https://site.com/*"]}, + {resources: ["img/onlyraw.png"], matches: ["https://other.com/*"]}, + ], + }); + + const resources: any[] = (builder.build() as any).web_accessible_resources; + expect(Array.isArray(resources)).toBe(true); + + // MV2 flattens to unique list of strings + expect(resources).toEqual( + expect.arrayContaining([ + "img/a.png", + "img/b.png", + "img/c.png", + "img/common.png", + "img/raw.png", + "img/onlyraw.png", + ]) + ); + + // Ensure uniqueness + + expect(unique(resources)).toBe(true); + }); +}); + describe("ManifestBase mergeSpecific", () => { it("should perform a deep merge of browser specific settings", () => { const builder = new ManifestV3(Browser.Firefox); @@ -60,7 +556,59 @@ describe("ManifestBase mergeSpecific", () => { }, }); + builder.raw({ + browser_specific_settings: { + safari: { + strict_max_version: "20", + }, + }, + }); + const manifest: any = builder.build(); expect(manifest.browser_specific_settings.safari.strict_min_version).toBe("15"); + expect(manifest.browser_specific_settings.safari.strict_max_version).toBe("20"); + }); + + it("should use raw for gecko settings when specific is not set", () => { + const builder = new ManifestV3(Browser.Firefox); + builder + .setSpecific({ + gecko: { + dataCollectionPermissions: { + required: [DataCollectionPermission.BrowsingActivity], + }, + }, + }) + .raw({ + browser_specific_settings: { + gecko: { + id: "from@optional", + update_url: "https://example.com/update.json", + strict_min_version: "110.0", + strict_max_version: "119.0", + data_collection_permissions: { + required: [DataCollectionPermission.WebsiteActivity], + optional: [DataCollectionPermission.AuthenticationInfo], + }, + }, + gecko_android: { + strict_min_version: "110.0", + strict_max_version: "119.0", + }, + }, + }); + + const settings: any = builder.build().browser_specific_settings; + + expect(settings.gecko.id).toBe("from@optional"); + expect(settings.gecko.update_url).toBe("https://example.com/update.json"); + expect(settings.gecko.strict_min_version).toBe("110.0"); + expect(settings.gecko.strict_max_version).toBe("119.0"); + expect(settings.gecko.data_collection_permissions.required).toContain(DataCollectionPermission.WebsiteActivity); + expect(settings.gecko.data_collection_permissions.required).toContain( + DataCollectionPermission.BrowsingActivity + ); + expect(settings.gecko_android.strict_min_version).toBe("110.0"); + expect(settings.gecko_android.strict_max_version).toBe("119.0"); }); }); diff --git a/src/cli/builders/manifest/ManifestBase.ts b/src/cli/builders/manifest/ManifestBase.ts index 05200bb..dd1f9af 100644 --- a/src/cli/builders/manifest/ManifestBase.ts +++ b/src/cli/builders/manifest/ManifestBase.ts @@ -20,6 +20,7 @@ import { ManifestPopup, ManifestSidebar, ManifestVersion, + OptionalManifest, } from "@typing/manifest"; import {Browser, BrowserSpecific} from "@typing/browser"; import {Language} from "@typing/locale"; @@ -40,13 +41,13 @@ export class ManifestError extends Error { } export default abstract class implements ManifestBuilder { - protected name: string = "__MSG_app_name__"; + protected name?: string; protected author?: string; protected homepage?: string; protected shortName?: string; protected description?: string; protected minimumVersion?: string; - protected version: string = "0.0.0"; + protected version?: string; protected icon?: string; protected incognito?: ManifestIncognito; protected specific?: BrowserSpecific; @@ -64,6 +65,9 @@ export default abstract class implements ManifestBuilder protected optionalHostPermissions: ManifestHostPermissions = new Set(); protected accessibleResources: ManifestAccessibleResources = new Set(); + protected raws: Set = new Set(); + protected mergedRaws?: OptionalManifest; + public abstract getManifestVersion(): ManifestVersion; protected abstract buildAction(): Partial | undefined; @@ -111,7 +115,7 @@ export default abstract class implements ManifestBuilder } public setVersion(version?: string): this { - this.version = version || "0.0.0"; + this.version = version; return this; } @@ -292,43 +296,29 @@ export default abstract class implements ManifestBuilder return this; } - public setManifestAccessibleResource(accessibleResources: ManifestAccessibleResources): this { + public setAccessibleResource(accessibleResources: ManifestAccessibleResources): this { this.accessibleResources = accessibleResources; return this; } - private merge(manifest: T, ...sources: Array | undefined>): T { - sources = sources.filter(source => source !== undefined); - - if (sources.length === 0) { - return manifest; - } - - const result = {...manifest}; - - for (const source of sources) { - Object.assign(result, source); - } + public raw(manifest: OptionalManifest): this { + this.raws.add(manifest); - return result; + return this; } public build(): T { - let manifest: Manifest = { - name: this.name, - short_name: this.shortName, - description: this.description, - version: this.version, - manifest_version: this.getManifestVersion(), - minimum_chrome_version: this.minimumVersion, - author: this.author, - homepage_url: this.homepage, - incognito: this.incognito, - }; - - manifest = this.merge( - manifest, + return this.merge( + this.buildName(), + this.buildShortName(), + this.buildDescription(), + this.buildVersion(), + this.buildManifestVersion(), + this.buildMinimumChromeVersion(), + this.buildAuthor(), + this.buildHomepageUrl(), + this.buildIncognito(), this.buildLocale(), this.buildIcons(), this.buildBackground(), @@ -341,16 +331,138 @@ export default abstract class implements ManifestBuilder this.buildHostPermissions(), this.buildOptionalHostPermissions(), this.buildWebAccessibleResources(), - this.buildBrowserSpecificSettings() - ); + this.buildBrowserSpecificSettings(), + this.buildRaw() + ) as T; + } - return manifest as T; + public get(): T { + return this.build(); } - protected buildIcons(): Partial | undefined { - if (this.icon) { - return {icons: this.getIconsByName(this.icon)}; + protected get combinedRaws(): OptionalManifest { + if (this.mergedRaws) return this.mergedRaws; + + this.mergedRaws = Array.from(this.raws).reduce((result, raw) => { + return _.mergeWith(result, raw, (objValue, srcValue) => { + if (Array.isArray(objValue) && Array.isArray(srcValue)) { + return objValue.concat(srcValue); + } + }); + }, {}); + + return this.mergedRaws; + } + + protected get combinedPermissions(): ManifestPermissions { + const result = new Set(this.permissions); + if (this.combinedRaws.permissions) { + for (const permission of this.combinedRaws.permissions) { + result.add(permission); + } } + return result; + } + + protected get combinedOptionalPermissions(): ManifestOptionalPermissions { + const result = new Set(this.optionalPermissions); + if (this.combinedRaws.optional_permissions) { + for (const permission of this.combinedRaws.optional_permissions) { + result.add(permission); + } + } + return result; + } + + protected get combinedHostPermissions(): ManifestHostPermissions { + const result = new Set(this.hostPermissions); + if (this.combinedRaws.host_permissions) { + for (const permission of this.combinedRaws.host_permissions) { + result.add(permission); + } + } + return result; + } + + protected get combinedOptionalHostPermissions(): ManifestHostPermissions { + const result = new Set(this.optionalHostPermissions); + if (this.combinedRaws.optional_host_permissions) { + for (const permission of this.combinedRaws.optional_host_permissions) { + result.add(permission); + } + } + return result; + } + + private merge(...sources: Array | undefined>): T { + sources = sources.filter(source => source !== undefined); + + if (sources.length === 0) { + throw new ManifestError("No sources provided for manifest merging"); + } + + const result = {} as T; + + for (const source of sources) { + Object.assign(result, source); + } + + return result; + } + + protected buildName(): Partial { + return {name: this.name || this.combinedRaws.name || "__MSG_app_name__"}; + } + + protected buildShortName(): Partial | undefined { + const shortName = this.shortName || this.combinedRaws.short_name; + return shortName ? {short_name: shortName} : undefined; + } + + protected buildDescription(): Partial | undefined { + const description = this.description || this.combinedRaws.description; + return description ? {description} : undefined; + } + + protected buildVersion(): Partial { + return {version: this.version || this.combinedRaws.version || "0.0.0"}; + } + + protected buildManifestVersion(): Partial { + return {manifest_version: this.getManifestVersion()}; + } + + protected buildMinimumChromeVersion(): Partial | undefined { + const version = this.minimumVersion || this.combinedRaws.minimum_chrome_version; + return version ? {minimum_chrome_version: version} : undefined; + } + + protected buildAuthor(): Partial | undefined { + const author = this.author || this.combinedRaws.author; + return author ? {author} : undefined; + } + + protected buildHomepageUrl(): Partial | undefined { + const homepage = this.homepage || this.combinedRaws.homepage_url; + return homepage ? {homepage_url: homepage} : undefined; + } + + protected buildIncognito(): Partial | undefined { + const incognito = this.incognito || this.combinedRaws.incognito; + return incognito !== undefined ? {incognito} : undefined; + } + + protected buildLocale(): Partial | undefined { + const defaultLocale = this.locale || this.combinedRaws.default_locale; + return defaultLocale ? {default_locale: defaultLocale} : undefined; + } + + protected buildIcons(): Partial | undefined { + const icons = { + ...this.combinedRaws.icons, + ...this.getIconsByName(this.icon), + }; + return Object.keys(icons).length ? {icons} : undefined; } protected buildBackground(): Partial | undefined { @@ -374,10 +486,11 @@ export default abstract class implements ManifestBuilder } protected buildCommands(): Partial | undefined { - if (this.commands.size > 0) { - const commands = Array.from(this.commands).reduce( - (commands, command) => { - const item = { + const internalCommands = Array.from(this.commands).reduce( + (commands, command) => { + return { + ...commands, + [command.name]: { suggested_key: { default: command?.defaultKey, windows: command?.windowsKey, @@ -389,21 +502,25 @@ export default abstract class implements ManifestBuilder command?.description || (command.name === CommandExecuteActionName ? undefined : command.name), global: command?.global, - }; + }, + }; + }, + {} as CoreManifest["commands"] + ); - return {...commands, [command.name]: item}; - }, - {} as CoreManifest["commands"] - ); + const commands = _.merge(this.combinedRaws.commands, internalCommands); - return {commands}; - } + if (Object.keys(commands).length) return {commands}; } protected buildContentScripts(): Partial | undefined { - if (this.contentScripts.size > 0) { - const contentScripts: ManifestV3["content_scripts"] = []; + const contentScripts: ManifestV3["content_scripts"] = []; + + if (this.combinedRaws.content_scripts) { + contentScripts.push(...this.combinedRaws.content_scripts); + } + if (this.contentScripts.size > 0) { for (const script of this.contentScripts.values()) { const { entry, @@ -446,13 +563,22 @@ export default abstract class implements ManifestBuilder world, }); } - - return {content_scripts: contentScripts}; } + + return contentScripts.length ? {content_scripts: contentScripts} : undefined; } protected buildSidebar(): Partial | undefined { if (!this.sidebar) { + const sidebarAction = this.combinedRaws.sidebar_action; + const sidePanel = this.combinedRaws.side_panel; + + if (SidebarAlternativeBrowsers.has(this.browser)) { + if (sidebarAction) return {sidebar_action: sidebarAction}; + } else { + if (sidePanel) return {side_panel: sidePanel}; + } + return; } @@ -469,57 +595,103 @@ export default abstract class implements ManifestBuilder : {side_panel: {...commonProps, default_path: path}}; } - protected buildLocale(): Partial | undefined { - if (this.locale) { - return {default_locale: this.locale}; - } - } - protected buildBrowserSpecificSettings(): Partial | undefined { - const settings = this.specific || {}; - const {safari, gecko, geckoAndroid} = settings; + const optionalSettings = this.combinedRaws.browser_specific_settings; + const {safari, gecko, geckoAndroid} = this.specific || {}; if (this.browser === Browser.Firefox) { - const emptyGeckoAndroid = - _.isEmpty(geckoAndroid?.strictMinVersion) && _.isEmpty(geckoAndroid?.strictMaxVersion); + const id = gecko?.id || optionalSettings?.gecko?.id; + const updateUrl = gecko?.updateUrl || optionalSettings?.gecko?.update_url; + const geckoMinVersion = gecko?.strictMinVersion || optionalSettings?.gecko?.strict_min_version; + const geckoMaxVersion = gecko?.strictMaxVersion || optionalSettings?.gecko?.strict_max_version; + const dataCollectionPermissions = _.mergeWith( + optionalSettings?.gecko?.data_collection_permissions, + gecko?.dataCollectionPermissions, + (objValue, srcValue) => { + if (Array.isArray(objValue) && Array.isArray(srcValue)) { + return objValue.concat(srcValue); + } + } + ); + + const androidMinVersion = + geckoAndroid?.strictMinVersion || optionalSettings?.gecko_android?.strict_min_version; + const androidMaxVersion = + geckoAndroid?.strictMaxVersion || optionalSettings?.gecko_android?.strict_max_version; return { browser_specific_settings: { gecko: { - id: gecko?.id, - strict_min_version: gecko?.strictMinVersion, - strict_max_version: gecko?.strictMaxVersion, - update_url: gecko?.updateUrl, - data_collection_permissions: normalizeDataCollectionPermissions( - gecko?.dataCollectionPermissions - ), + id, + update_url: updateUrl, + strict_min_version: geckoMinVersion, + strict_max_version: geckoMaxVersion, + data_collection_permissions: normalizeDataCollectionPermissions(dataCollectionPermissions), }, - gecko_android: emptyGeckoAndroid - ? undefined - : { - strict_min_version: geckoAndroid?.strictMinVersion, - strict_max_version: geckoAndroid?.strictMaxVersion, - }, + gecko_android: + _.isEmpty(androidMinVersion) && _.isEmpty(androidMaxVersion) + ? undefined + : { + strict_min_version: androidMinVersion, + strict_max_version: androidMaxVersion, + }, }, }; } else if (this.browser === Browser.Safari) { - if (_.isEmpty(safari?.strictMinVersion) && _.isEmpty(safari?.strictMaxVersion)) { + const minVersion = safari?.strictMinVersion || optionalSettings?.safari?.strict_min_version; + const maxVersion = safari?.strictMaxVersion || optionalSettings?.safari?.strict_max_version; + + if (_.isEmpty(minVersion) && _.isEmpty(maxVersion)) { return; } return { browser_specific_settings: { safari: { - strict_min_version: safari?.strictMinVersion, - strict_max_version: safari?.strictMaxVersion, + strict_min_version: minVersion, + strict_max_version: maxVersion, }, }, }; } } + protected buildRaw(): Partial | undefined { + const { + name, + short_name, + description, + version, + minimum_chrome_version, + author, + homepage_url, + incognito, + default_locale, + icons, + background, + commands, + action, + sidebar, + content_scripts, + permissions, + optional_permissions, + host_permissions, + optional_host_permissions, + web_accessible_resources, + browser_specific_settings, + ...other + } = this.combinedRaws; + + return other; + } + protected hasExecuteActionCommand(): boolean { - return this.commands.size > 0 && Array.from(this.commands).some(({name}) => name === CommandExecuteActionName); + const optionalCommands = this.combinedRaws.commands; + + const inInternalCommands = + this.commands.size > 0 && Array.from(this.commands).some(({name}) => name === CommandExecuteActionName); + const inOptionalCommands = optionalCommands && Object.keys(optionalCommands).includes(CommandExecuteActionName); + return inInternalCommands || inOptionalCommands; } protected getIconsByName(name?: string): CoreManifestIcons | undefined { @@ -538,10 +710,6 @@ export default abstract class implements ManifestBuilder } } - public get(): T { - return this.build(); - } - public getWebAccessibleResources(): ManifestAccessibleResource[] { const resources: ManifestAccessibleResource[] = [...this.accessibleResources]; @@ -556,6 +724,10 @@ export default abstract class implements ManifestBuilder } } + if (this.combinedRaws.web_accessible_resources) { + resources.push(...this.combinedRaws.web_accessible_resources); + } + return mergeWebAccessibleResources(resources); } } diff --git a/src/cli/builders/manifest/ManifestV2.ts b/src/cli/builders/manifest/ManifestV2.ts index 97e5070..8ca5f81 100644 --- a/src/cli/builders/manifest/ManifestV2.ts +++ b/src/cli/builders/manifest/ManifestV2.ts @@ -1,6 +1,6 @@ import ManifestBase from "./ManifestBase"; -import {filterHostPatterns, filterPermissionsForMV2} from "./utils"; +import {filterHostPatterns, filterOptionalPermissions, filterPermissionsForMV2} from "./utils"; import {CoreManifest, ManifestVersion} from "@typing/manifest"; import {Browser} from "@typing/browser"; @@ -65,10 +65,10 @@ export default class extends ManifestBase { } protected buildPermissions(): Partial | undefined { - const permissions: string[] = Array.from(filterPermissionsForMV2(this.permissions)); + const permissions: string[] = Array.from(filterPermissionsForMV2(this.combinedPermissions)); - if (this.hostPermissions.size > 0) { - permissions.push(...filterHostPatterns(this.hostPermissions)); + if (this.combinedHostPermissions.size > 0) { + permissions.push(...filterHostPatterns(this.combinedHostPermissions)); } if (permissions.length > 0) { @@ -77,14 +77,15 @@ export default class extends ManifestBase { } protected buildOptionalPermissions(): Partial | undefined { - const optionalPermissions: string[] = Array.from(filterPermissionsForMV2(this.optionalPermissions)).filter( - permission => !this.permissions.has(permission) + const optionalPermissions: string[] = filterOptionalPermissions( + filterPermissionsForMV2(this.combinedOptionalPermissions), + filterPermissionsForMV2(this.combinedPermissions) ); // prettier-ignore const optionalHostPermissions: string[] = Array - .from(filterHostPatterns(new Set([...this.hostPermissions, ...this.optionalHostPermissions]))) - .filter((permission) => !this.hostPermissions.has(permission)); + .from(filterHostPatterns(new Set([...this.combinedHostPermissions, ...this.combinedOptionalHostPermissions]))) + .filter((permission) => !this.combinedHostPermissions.has(permission)); if (optionalHostPermissions.length > 0) { optionalPermissions.push(...optionalHostPermissions); diff --git a/src/cli/builders/manifest/ManifestV3.ts b/src/cli/builders/manifest/ManifestV3.ts index 2e8c3dc..c8bdccb 100644 --- a/src/cli/builders/manifest/ManifestV3.ts +++ b/src/cli/builders/manifest/ManifestV3.ts @@ -1,6 +1,6 @@ import ManifestBase, {ManifestError} from "./ManifestBase"; -import {filterHostPatterns, filterPermissionsForMV3} from "./utils"; +import {filterHostPatterns, filterOptionalPermissions, filterPermissionsForMV3} from "./utils"; import {CoreManifest, ManifestAccessibleResource, ManifestVersion} from "@typing/manifest"; import {Browser} from "@typing/browser"; @@ -73,7 +73,7 @@ export default class extends ManifestBase { } protected buildPermissions(): Partial | undefined { - const permissions = Array.from(filterPermissionsForMV3(this.permissions)); + const permissions = Array.from(filterPermissionsForMV3(this.combinedPermissions)); if (permissions.length > 0) { return {permissions}; @@ -81,10 +81,10 @@ export default class extends ManifestBase { } protected buildOptionalPermissions(): Partial | undefined { - // prettier-ignore - const optionalPermissions = Array - .from(filterPermissionsForMV3(this.optionalPermissions)) - .filter((permission) => !this.permissions.has(permission)); + const optionalPermissions = filterOptionalPermissions( + filterPermissionsForMV3(this.combinedOptionalPermissions), + filterPermissionsForMV3(this.combinedPermissions) + ); if (optionalPermissions.length > 0) { return {optional_permissions: optionalPermissions}; @@ -92,16 +92,16 @@ export default class extends ManifestBase { } protected buildHostPermissions(): Partial | undefined { - if (this.hostPermissions.size > 0) { - return {host_permissions: [...filterHostPatterns(this.hostPermissions)]}; + if (this.combinedHostPermissions.size > 0) { + return {host_permissions: [...filterHostPatterns(this.combinedHostPermissions)]}; } } protected buildOptionalHostPermissions(): Partial | undefined { // prettier-ignore const optionalHostPermissions = Array - .from(filterHostPatterns(new Set([...this.hostPermissions, ...this.optionalHostPermissions]))) - .filter((permission) => !this.hostPermissions.has(permission)); + .from(filterHostPatterns(new Set([...this.combinedHostPermissions, ...this.combinedOptionalHostPermissions]))) + .filter((permission) => !this.combinedHostPermissions.has(permission)); if (optionalHostPermissions.length > 0) { return {optional_host_permissions: optionalHostPermissions}; diff --git a/src/cli/builders/manifest/utils.test.ts b/src/cli/builders/manifest/utils.test.ts index f5a669f..dfd6c52 100644 --- a/src/cli/builders/manifest/utils.test.ts +++ b/src/cli/builders/manifest/utils.test.ts @@ -1,8 +1,16 @@ -import {filterHostPatterns, mergeWebAccessibleResources, normalizeDataCollectionPermissions} from "./utils"; +import { + filterHostPatterns, + filterOptionalPermissions, + mergeWebAccessibleResources, + normalizeDataCollectionPermissions, +} from "./utils"; import {DataCollectionPermission} from "@typing/browser"; import {ManifestAccessibleResource} from "@typing/manifest"; +type ManifestPermission = chrome.runtime.ManifestPermission; +type ManifestOptionalPermission = chrome.runtime.ManifestOptionalPermission; + const toSet = (arr: string[]) => new Set(arr); const setToArray = (set: Set) => Array.from(set); const sortResources = (resources: ManifestAccessibleResource[]): ManifestAccessibleResource[] => { @@ -96,6 +104,58 @@ describe("filterHostPatterns", () => { }); }); +describe("filterOptionalPermissions", () => { + test("removes permissions that are already required", () => { + const required = new Set(["storage"]); + const optional = new Set(["storage", "tabs"]); + + const result = filterOptionalPermissions(optional, required); + + expect(result).toEqual(expect.arrayContaining(["tabs"])); + expect(result).not.toEqual(expect.arrayContaining(["storage"])); + expect(result.length).toBe(1); + }); + + test("drops activeTab from optional when tabs is present in optional", () => { + const optional = new Set(["activeTab", "tabs"]); + const required = new Set(); + + const result = filterOptionalPermissions(optional, required); + + // filterPermissions removes activeTab when tabs is present in the union + expect(result).toEqual(["tabs"]); + }); + + test("drops activeTab from optional when tabs is present in required", () => { + const optional = new Set(["activeTab"]); + const required = new Set(["tabs"]); + + const result = filterOptionalPermissions(optional, required); + + // Union contains tabs and activeTab; filterPermissions removes activeTab, then diff removes tabs as required -> empty + expect(result).toEqual([]); + }); + + test("keeps activeTab when tabs is absent from both optional and required", () => { + const optional = new Set(["activeTab"]); + const required = new Set(); + + const result = filterOptionalPermissions(optional, required); + + expect(result).toEqual(["activeTab"]); + }); + + test("deduplicates and filters correctly when mixing optional and required", () => { + const optional = new Set(["tabs", "storage", "activeTab"]); + const required = new Set(["storage"]); + + const result = filterOptionalPermissions(optional, required); + + // activeTab should be removed because tabs is present; storage removed because it's required + expect(result).toEqual(["tabs"]); + }); +}); + describe("mergeWebAccessibleResources", () => { test("merge resources with same matches without duplicates", () => { const input = [ diff --git a/src/cli/builders/manifest/utils.ts b/src/cli/builders/manifest/utils.ts index b4c8e91..e0bbed7 100644 --- a/src/cli/builders/manifest/utils.ts +++ b/src/cli/builders/manifest/utils.ts @@ -13,6 +13,13 @@ type Permission = ManifestPermissions | ManifestOptionalPermissions; * @param permissions - Set of permissions to filter * @returns New set of permissions adapted for Manifest V2 */ +export const filterPermissions = (permissions: Set): Set => { + if (permissions.has("tabs" as T)) { + permissions.delete("activeTab" as T); + } + return permissions; +}; + export const filterPermissionsForMV2 = (permissions: Set): Set => { const filteredPermissions = new Set(permissions); @@ -35,7 +42,7 @@ export const filterPermissionsForMV2 = (permissions: Set(permissions: Set): Set => { @@ -53,7 +60,15 @@ export const filterPermissionsForMV3 = (permissions: Set( + optional: Set, + required: Set +): O[] => { + const allPermissions = filterPermissions(new Set([...optional, ...required])); + return _.difference(Array.from(allPermissions), Array.from(required)) as O[]; }; export const filterHostPatterns = (patterns: Set): Set => { diff --git a/src/cli/plugins/index.ts b/src/cli/plugins/index.ts index ece5e46..40f0daf 100644 --- a/src/cli/plugins/index.ts +++ b/src/cli/plugins/index.ts @@ -4,6 +4,7 @@ export {default as pluginBackground} from "./background"; export {default as pluginContent} from "./content"; export {default as pluginDotenv} from "./dotenv"; export {default as pluginHtml} from "./html"; +export {default as pluginManifest} from "./manifest"; export {default as pluginOptimization} from "./optimization"; export {default as pluginOutput} from "./output"; export {default as pluginIcon} from "./icon"; diff --git a/src/cli/plugins/manifest.ts b/src/cli/plugins/manifest.ts new file mode 100644 index 0000000..c0c9de1 --- /dev/null +++ b/src/cli/plugins/manifest.ts @@ -0,0 +1,28 @@ +import {definePlugin} from "@main/plugin"; +import {fromRootPath} from "@cli/resolvers/path"; +import fs from "fs"; + +export default definePlugin(() => { + return { + name: "adnbn:manifest", + manifest: ({config, manifest}) => { + try { + const packagePath = fromRootPath(config, "package.json"); + + const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf-8")); + + packageJson.manifest && manifest.raw(packageJson.manifest); + } catch (e) {} + + const configManifest = config.manifest; + + if (typeof configManifest === "object") { + manifest.raw(configManifest); + } else if (typeof configManifest === "function") { + const result = configManifest(manifest); + + result && manifest.raw(result); + } + }, + }; +}); diff --git a/src/cli/plugins/version/AbstractVersion.ts b/src/cli/plugins/version/AbstractVersion.ts index 8fa7f02..b0576e4 100644 --- a/src/cli/plugins/version/AbstractVersion.ts +++ b/src/cli/plugins/version/AbstractVersion.ts @@ -19,6 +19,6 @@ export default abstract class AbstractVersion { return; } - return String(_.isFunction(version) ? version : version); + return String(_.isFunction(version) ? version() : version); } } diff --git a/src/cli/resolvers/config.ts b/src/cli/resolvers/config.ts index e5ce7b8..d2800b7 100644 --- a/src/cli/resolvers/config.ts +++ b/src/cli/resolvers/config.ts @@ -14,6 +14,7 @@ import { pluginLocale, pluginMeta, pluginOffscreen, + pluginManifest, pluginOptimization, pluginOutput, pluginPage, @@ -193,6 +194,7 @@ export default async (config: OptionalConfig): Promise => { html = [], bundler = {}, env = {}, + manifest, manifestVersion = (new Set([Browser.Safari]).has(browser) ? 2 : 3) as ManifestVersion, mode = Mode.Development, analyze = false, @@ -246,6 +248,7 @@ export default async (config: OptionalConfig): Promise => { icon, incognito, specific, + manifest, manifestVersion, rootDir, outDir, @@ -326,6 +329,7 @@ export default async (config: OptionalConfig): Promise => { pluginHtml(), pluginVersion(), pluginBundler(), + pluginManifest(), ]; return { diff --git a/src/types/config.ts b/src/types/config.ts index 784525f..ada3e2a 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -3,7 +3,7 @@ import type {Options as HtmlOptions} from "html-rspack-tags-plugin"; import {Command, Mode} from "@typing/app"; import {Browser, BrowserSpecific} from "@typing/browser"; -import {ManifestIncognitoValue, ManifestVersion} from "@typing/manifest"; +import {ManifestIncognitoValue, ManifestVersion, ManifestBuilder, OptionalManifest} from "@typing/manifest"; import {Plugin} from "@typing/plugin"; import {Language} from "@typing/locale"; import {Awaiter} from "@typing/helpers"; @@ -171,6 +171,20 @@ export interface Config { */ incognito?: ManifestIncognitoValue | (() => ManifestIncognitoValue | undefined); + /** + * Extension manifest without the version. + * Allows customizing the manifest.json file beyond the standard fields handled by the builder. + * The structure and available APIs depend on the manifest version (v2 or v3). + * + * Accepts: + * - an object with additional manifest fields + * - a function that receives a ManifestBuilder instance and returns manifest fields + * + * Note: Some fields like name, version, and permissions are handled automatically + * by the builder and should not be included here unless you need to override them. + */ + manifest?: OptionalManifest | ((builder: ManifestBuilder) => OptionalManifest | undefined); + /** * Extension manifest version (e.g., v2 or v3). * Defines the manifest structure and available APIs. diff --git a/src/types/manifest.ts b/src/types/manifest.ts index 3275168..70fb335 100644 --- a/src/types/manifest.ts +++ b/src/types/manifest.ts @@ -84,6 +84,8 @@ export type SafariManifest = ChromeManifest & { export type Manifest = ChromeManifest | FirefoxManifest | SafariManifest; +export type OptionalManifest = Partial>; + export interface ManifestBuilder { setName(name: string): this; @@ -156,7 +158,7 @@ export interface ManifestBuilder { appendOptionalHostPermissions(permissions: ManifestHostPermissions): this; // Web Accessible Resource - setManifestAccessibleResource(accessibleResources: ManifestAccessibleResources): this; + setAccessibleResource(accessibleResources: ManifestAccessibleResources): this; appendAccessibleResources(accessibleResources: ManifestAccessibleResources): this; @@ -166,6 +168,8 @@ export interface ManifestBuilder { // Getter get(): T; + + raw(manifest: OptionalManifest): this; } type Entry = string;