diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index e6c77b366b1..8063306662d 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -1299,6 +1299,7 @@ jobs: $package = Get-Content -Raw -Path electron-builder.json | ConvertFrom-Json $package | Add-Member -MemberType NoteProperty -Name buildVersion -Value "$env:BUILD_NUMBER" $package | ConvertTo-Json -Depth 32 | Set-Content -Path electron-builder.json + Write-Output "### MacOS App Store build number: $env:BUILD_NUMBER" - name: Install Node dependencies @@ -1374,6 +1375,23 @@ jobs: CSC_FOR_PULL_REQUEST: true run: npm run pack:mac:mas + - name: Create MacOS App Store build number artifact + shell: pwsh + env: + BUILD_NUMBER: ${{ needs.setup.outputs.build_number }} + run: | + $buildInfo = @{ + buildNumber = $env:BUILD_NUMBER + } + $buildInfo | ConvertTo-Json | Set-Content -Path dist/macos-build-number.json + + - name: Upload MacOS App Store build number artifact + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: macos-build-number.json + path: apps/desktop/dist/macos-build-number.json + if-no-files-found: error + - name: Upload .pkg artifact uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 with: diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml index aafc4d25ed4..9fe8909f8d6 100644 --- a/.github/workflows/publish-desktop.yml +++ b/.github/workflows/publish-desktop.yml @@ -18,10 +18,15 @@ on: type: string default: latest electron_rollout_percentage: - description: 'Staged Rollout Percentage for Electron' - required: true + description: 'Staged Rollout Percentage for Electron (ignored if Electron publish disabled)' + required: false default: '10' type: string + electron_publish: + description: 'Publish to Electron (auto-updater)' + required: true + default: true + type: boolean snap_publish: description: 'Publish to Snap store' required: true @@ -32,6 +37,15 @@ on: required: true default: true type: boolean + mas_publish: + description: 'Publish to Mac App Store' + required: true + default: true + type: boolean + release_notes: + description: 'Release Notes' + required: false + type: string jobs: setup: @@ -71,7 +85,7 @@ jobs: echo "Release Version: ${{ inputs.version }}" echo "version=${{ inputs.version }}" - $TAG_NAME="desktop-v${{ inputs.version }}" + TAG_NAME="desktop-v${{ inputs.version }}" echo "Tag name: $TAG_NAME" echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT @@ -109,6 +123,7 @@ jobs: name: Electron blob publish runs-on: ubuntu-22.04 needs: setup + if: inputs.electron_publish permissions: contents: read packages: read @@ -292,6 +307,92 @@ jobs: run: choco push --source=https://push.chocolatey.org/ working-directory: apps/desktop/dist + mas: + name: Deploy Mac App Store + runs-on: macos-15 + needs: setup + permissions: + contents: read + id-token: write + if: inputs.mas_publish + env: + _PKG_VERSION: ${{ needs.setup.outputs.release_version }} + _RELEASE_TAG: ${{ needs.setup.outputs.tag_name }} + steps: + - name: Checkout repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Validate release notes for MAS + if: inputs.mas_publish && (inputs.release_notes == '' || inputs.release_notes == null) + run: | + echo "โŒ Release notes are required when publishing to Mac App Store" + echo "Please provide release notes using the 'Release Notes' input field" + exit 1 + + - name: Download MacOS App Store build number + working-directory: apps/desktop + run: wget https://github.com/bitwarden/clients/releases/download/${{ env._RELEASE_TAG }}/macos-build-number.json + + - name: Setup Ruby and Install Fastlane + uses: ruby/setup-ruby@ca041f971d66735f3e5ff1e21cc13e2d51e7e535 # v1.233.0 + with: + ruby-version: '3.0' + bundler-cache: false + working-directory: apps/desktop + + - name: Install Fastlane + working-directory: apps/desktop + run: gem install fastlane + + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main + with: + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} + + - name: Get Azure Key Vault secrets + id: get-kv-secrets + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: gh-clients + secrets: "APP-STORE-CONNECT-AUTH-KEY,APP-STORE-CONNECT-TEAM-ISSUER" + + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + + - name: Publish to App Store + env: + APP_STORE_CONNECT_TEAM_ISSUER: ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-TEAM-ISSUER }} + APP_STORE_CONNECT_AUTH_KEY: ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-AUTH-KEY }} + working-directory: apps/desktop + run: | + BUILD_NUMBER=$(jq -r '.buildNumber' macos-build-number.json) + CHANGELOG="${{ inputs.release_notes }}" + IS_DRY_RUN="${{ inputs.publish_type == 'Dry Run' }}" + + if [ "$IS_DRY_RUN" = "true" ]; then + echo "๐Ÿงช DRY RUN MODE - Testing without actual App Store submission" + echo "๐Ÿ“ฆ Would publish build $BUILD_NUMBER to Mac App Store" + else + echo "๐Ÿš€ PRODUCTION MODE - Publishing to Mac App Store" + echo "๐Ÿ“ฆ Publishing build $BUILD_NUMBER to Mac App Store" + fi + + echo "๐Ÿ“ Release notes (${#CHANGELOG} chars): ${CHANGELOG:0:100}..." + + # Validate changelog length (App Store limit is 4000 chars) + if [ ${#CHANGELOG} -gt 4000 ]; then + echo "โŒ Release notes too long: ${#CHANGELOG} characters (max 4000)" + exit 1 + fi + + fastlane publish --verbose \ + app_version:"${{ env._PKG_VERSION }}" \ + build_number:$BUILD_NUMBER \ + changelog:"$CHANGELOG" \ + dry_run:$IS_DRY_RUN + update-deployment: name: Update Deployment Status runs-on: ubuntu-22.04 @@ -300,6 +401,7 @@ jobs: - electron-blob - snap - choco + - mas permissions: contents: read deployments: write diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 5ce0da4cb4b..bfd6115a1a9 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -124,7 +124,8 @@ jobs: apps/desktop/artifacts/Bitwarden-${{ env.PKG_VERSION }}-universal.pkg.archive, apps/desktop/artifacts/${{ env.RELEASE_CHANNEL }}.yml, apps/desktop/artifacts/${{ env.RELEASE_CHANNEL }}-linux.yml, - apps/desktop/artifacts/${{ env.RELEASE_CHANNEL }}-mac.yml" + apps/desktop/artifacts/${{ env.RELEASE_CHANNEL }}-mac.yml, + apps/desktop/artifacts/macos-build-number.json" commit: ${{ github.sha }} tag: desktop-v${{ env.PKG_VERSION }} name: Desktop v${{ env.PKG_VERSION }} diff --git a/apps/browser/package.json b/apps/browser/package.json index 320b6be4f7c..3cfc4377227 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/browser", - "version": "2025.8.1", + "version": "2025.8.2", "scripts": { "build": "npm run build:chrome", "build:chrome": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack", diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index fa249483b7d..bb2483daf3b 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1760,14 +1760,8 @@ "popupU2fCloseMessage": { "message": "This browser cannot process U2F requests in this popup window. Do you want to open this popup in a new window so that you can log in using U2F?" }, - "enableFavicon": { - "message": "Show website icons" - }, - "faviconDesc": { - "message": "Show a recognizable image next to each login." - }, - "faviconDescAlt": { - "message": "Show a recognizable image next to each login. Applies to all logged in accounts." + "showIconsChangePasswordUrls": { + "message": "Show website icons and retrieve change password URLs" }, "enableBadgeCounter": { "message": "Show badge counter" @@ -5575,6 +5569,12 @@ "message": "Easily create strong and unique passwords by clicking on the Generate password button to help you keep your logins secure.", "description": "Aria label for the body content of the generator nudge" }, + "aboutThisSetting": { + "message": "About this setting" + }, + "permitCipherDetailsDescription": { + "message": "Bitwarden will use saved login URIs to identify which icon or change password URL should be used to improve your experience. No information is collected or saved when you use this service." + }, "noPermissionsViewPage": { "message": "You do not have permissions to view this page. Try logging in with a different account." }, diff --git a/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-content.service.ts b/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-content.service.ts index dc5a756250b..31bb37c908e 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-content.service.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-content.service.ts @@ -9,5 +9,8 @@ export type InlineMenuExtensionMessageHandlers = { export interface AutofillInlineMenuContentService { messageHandlers: InlineMenuExtensionMessageHandlers; isElementInlineMenu(element: HTMLElement): boolean; + getOwnedTagNames: () => string[]; + getUnownedTopLayerItems: (includeCandidates?: boolean) => NodeListOf; + refreshTopLayerPosition: () => void; destroy(): void; } diff --git a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts index 2c9484c3a8b..f1a74556b24 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts @@ -42,9 +42,6 @@ describe("AutofillInlineMenuContentService", () => { "sendExtensionMessage", ); jest.spyOn(autofillInlineMenuContentService as any, "getPageIsOpaque"); - jest - .spyOn(autofillInlineMenuContentService as any, "getPageTopLayerInUse") - .mockResolvedValue(false); }); afterEach(() => { @@ -390,20 +387,6 @@ describe("AutofillInlineMenuContentService", () => { expect(globalThis.document.body.insertBefore).not.toHaveBeenCalled(); }); - it("closes the inline menu if the page has content in the top layer", async () => { - document.querySelector("html").style.opacity = "1"; - document.body.style.opacity = "1"; - - jest - .spyOn(autofillInlineMenuContentService as any, "getPageTopLayerInUse") - .mockResolvedValue(true); - - await autofillInlineMenuContentService["handlePageMutations"]([mockBodyMutationRecord]); - - expect(autofillInlineMenuContentService["getPageIsOpaque"]).toHaveReturnedWith(true); - expect(autofillInlineMenuContentService["closeInlineMenu"]).toHaveBeenCalled(); - }); - it("closes the inline menu if the page body is not sufficiently opaque", async () => { document.querySelector("html").style.opacity = "0.9"; document.body.style.opacity = "0"; diff --git a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts index c531215af88..247104e13a5 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts @@ -159,6 +159,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte if (!(await this.isInlineMenuButtonVisible())) { this.appendInlineMenuElementToDom(this.buttonElement); this.updateInlineMenuElementIsVisibleStatus(AutofillOverlayElement.Button, true); + this.buttonElement.showPopover(); } } @@ -174,6 +175,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte if (!(await this.isInlineMenuListVisible())) { this.appendInlineMenuElementToDom(this.listElement); this.updateInlineMenuElementIsVisibleStatus(AutofillOverlayElement.List, true); + this.listElement.showPopover(); } } @@ -219,6 +221,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte private createButtonElement() { if (this.isFirefoxBrowser) { this.buttonElement = globalThis.document.createElement("div"); + this.buttonElement.setAttribute("popover", "manual"); new AutofillInlineMenuButtonIframe(this.buttonElement); return; @@ -235,6 +238,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte }, ); this.buttonElement = globalThis.document.createElement(customElementName); + this.buttonElement.setAttribute("popover", "manual"); } /** @@ -244,6 +248,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte private createListElement() { if (this.isFirefoxBrowser) { this.listElement = globalThis.document.createElement("div"); + this.listElement.setAttribute("popover", "manual"); new AutofillInlineMenuListIframe(this.listElement); return; @@ -260,6 +265,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte }, ); this.listElement = globalThis.document.createElement(customElementName); + this.listElement.setAttribute("popover", "manual"); } /** @@ -293,6 +299,8 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte this.containerElementMutationObserver = new MutationObserver( this.handleContainerElementMutationObserverUpdate, ); + + this.observePageAttributes(); }; /** @@ -300,9 +308,6 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * elements are not modified by the website. */ private observeCustomElements() { - this.htmlMutationObserver?.observe(document.querySelector("html"), { attributes: true }); - this.bodyMutationObserver?.observe(document.body, { attributes: true }); - if (this.buttonElement) { this.inlineMenuElementsMutationObserver?.observe(this.buttonElement, { attributes: true, @@ -314,6 +319,25 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte } } + /** + * Sets up mutation observers to verify that the page `html` and `body` attributes + * are not altered in a way that would impact safe display of the inline menu. + */ + private observePageAttributes() { + if (document.documentElement) { + this.htmlMutationObserver?.observe(document.documentElement, { attributes: true }); + } + + if (document.body) { + this.bodyMutationObserver?.observe(document.body, { attributes: true }); + } + } + + private unobservePageAttributes() { + this.htmlMutationObserver?.disconnect(); + this.bodyMutationObserver?.disconnect(); + } + /** * Disconnects the mutation observers that are used to verify that the inline menu * elements are not modified by the website. @@ -405,9 +429,8 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte private checkPageRisks = async () => { const pageIsOpaque = await this.getPageIsOpaque(); - const pageTopLayerInUse = await this.getPageTopLayerInUse(); - const risksFound = !pageIsOpaque || pageTopLayerInUse; + const risksFound = !pageIsOpaque; if (risksFound) { this.closeInlineMenu(); @@ -426,12 +449,61 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte }; /** - * Checks if the page top layer has content (will obscure/overlap the inline menu) + * Returns the name of the generated container tags for usage internally to avoid + * unintentional targeting of the owned experience. */ - private getPageTopLayerInUse = () => { - const pageHasOpenPopover = !!globalThis.document.querySelector(":popover-open"); + getOwnedTagNames = (): string[] => { + return [ + ...(this.buttonElement?.tagName ? [this.buttonElement.tagName] : []), + ...(this.listElement?.tagName ? [this.listElement.tagName] : []), + ]; + }; - return pageHasOpenPopover; + /** + * Queries and return elements (excluding those of the inline menu) that exist in the + * top-layer via popover or dialog + * @param {boolean} [includeCandidates=false] indicate whether top-layer candidate (which + * may or may not be active) should be included in the query + */ + getUnownedTopLayerItems = (includeCandidates = false) => { + const inlineMenuTagExclusions = [ + ...(this.buttonElement?.tagName ? [`:not(${this.buttonElement.tagName})`] : []), + ...(this.listElement?.tagName ? [`:not(${this.listElement.tagName})`] : []), + ":popover-open", + ].join(""); + const selector = [ + ":modal", + inlineMenuTagExclusions, + ...(includeCandidates ? ["[popover], dialog"] : []), + ].join(","); + const otherTopLayeritems = globalThis.document.querySelectorAll(selector); + + return otherTopLayeritems; + }; + + refreshTopLayerPosition = () => { + const otherTopLayerItems = this.getUnownedTopLayerItems(); + + // No need to refresh if there are no other top-layer items + if (!otherTopLayerItems.length) { + return; + } + + const buttonInDocument = + this.buttonElement && + (globalThis.document.getElementsByTagName(this.buttonElement.tagName)[0] as HTMLElement); + const listInDocument = + this.listElement && + (globalThis.document.getElementsByTagName(this.listElement.tagName)[0] as HTMLElement); + if (buttonInDocument) { + buttonInDocument.hidePopover(); + buttonInDocument.showPopover(); + } + + if (listInDocument) { + listInDocument.hidePopover(); + listInDocument.showPopover(); + } }; /** @@ -443,12 +515,17 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte private getPageIsOpaque = () => { // These are computed style values, so we don't need to worry about non-float values // for `opacity`, here - const htmlOpacity = globalThis.window.getComputedStyle( - globalThis.document.querySelector("html"), - ).opacity; - const bodyOpacity = globalThis.window.getComputedStyle( - globalThis.document.querySelector("body"), - ).opacity; + // @TODO for definitive checks, traverse up the node tree from the inline menu container; + // nodes can exist between `html` and `body` + const htmlElement = globalThis.document.querySelector("html"); + const bodyElement = globalThis.document.querySelector("body"); + + if (!htmlElement || !bodyElement) { + return false; + } + + const htmlOpacity = globalThis.window.getComputedStyle(htmlElement)?.opacity || "0"; + const bodyOpacity = globalThis.window.getComputedStyle(bodyElement)?.opacity || "0"; // Any value above this is considered "opaque" for our purposes const opacityThreshold = 0.6; @@ -607,5 +684,6 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte destroy() { this.closeInlineMenu(); this.clearPersistentLastChildOverrideTimeout(); + this.unobservePageAttributes(); } } diff --git a/apps/browser/src/autofill/overlay/notifications/content/__snapshots__/overlay-notifications-content.service.spec.ts.snap b/apps/browser/src/autofill/overlay/notifications/content/__snapshots__/overlay-notifications-content.service.spec.ts.snap index 18c3baa876c..7bdde2560d0 100644 --- a/apps/browser/src/autofill/overlay/notifications/content/__snapshots__/overlay-notifications-content.service.spec.ts.snap +++ b/apps/browser/src/autofill/overlay/notifications/content/__snapshots__/overlay-notifications-content.service.spec.ts.snap @@ -8,7 +8,7 @@ exports[`OverlayNotificationsContentService opening the notification bar creates