Skip to content
Draft
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
7 changes: 7 additions & 0 deletions apps/browser/src/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -2994,6 +2994,13 @@
"maxFolderSize": {
"message": "Maximum folder size is 500 MB."
},
"dropFilesToCreateSend": {
"message": "Drop files here to create a Send",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"dropFilesHere": {
"message": "Drop files here"
},
"allSends": {
"message": "All Sends",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
Expand Down
9 changes: 8 additions & 1 deletion apps/browser/src/popup/app.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,15 @@
} @else {
<!-- eslint-disable-next-line -->
<div class="tw-h-screen tw-w-screen">
<div [@routerTransition]="getRouteElevation(outlet)" class="tw-size-full">
<div [@routerTransition]="getRouteElevation(outlet)" class="tw-size-full tw-relative">
<router-outlet #outlet="outlet"></router-outlet>
@if (isDragOverApp()) {
<div
class="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center tw-bg-primary-100 tw-opacity-50 tw-text-primary-600 tw-font-semibold tw-z-50"
>
{{ "dropFilesToCreateSend" | i18n }}
</div>
}
</div>
<bit-toast-container></bit-toast-container>
</div>
Expand Down
65 changes: 65 additions & 0 deletions apps/browser/src/popup/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
NgZone,
OnDestroy,
OnInit,
signal,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router, RouterOutlet } from "@angular/router";
Expand Down Expand Up @@ -38,12 +39,15 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { AnimationControlService } from "@bitwarden/common/platform/abstractions/animation-control.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { MessageListener } from "@bitwarden/common/platform/messaging";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import {
Expand All @@ -53,6 +57,7 @@ import {
ToastService,
} from "@bitwarden/components";
import { BiometricsService, BiometricStateService, KeyService } from "@bitwarden/key-management";
import { PendingDragDropFilesService, setupAppDragDrop } from "@bitwarden/send-ui";

import BrowserPopupUtils from "../platform/browser/browser-popup-utils";
import { PopupCompactModeService } from "../platform/popup/layout/popup-compact-mode.service";
Expand All @@ -74,9 +79,15 @@ import { DesktopSyncVerificationDialogComponent } from "./components/desktop-syn
export class AppComponent implements OnInit, OnDestroy {
private compactModeService = inject(PopupCompactModeService);
private sdkService = inject(SdkService);
private pendingDragDropService = inject(PendingDragDropFilesService);
private configService = inject(ConfigService);

readonly isDragOverApp = signal(false);

private lastActivity: Date;
private activeUserId: UserId;
private isUnlocked = false;
private pendingDragDropFolderMode = false;
private routerAnimations = false;
private processingPendingAuthRequests = false;
private shouldRerunAuthRequestProcessing = false;
Expand Down Expand Up @@ -124,6 +135,7 @@ export class AppComponent implements OnInit, OnDestroy {

async ngOnInit() {
initPopupClosedListener();
await this.setupDragDropListeners();

this.compactModeService.init();
await this.popupSizeService.setHeight();
Expand All @@ -132,6 +144,10 @@ export class AppComponent implements OnInit, OnDestroy {
this.activeUserId = account?.id;
});

this.authService.activeAccountStatus$.pipe(takeUntil(this.destroy$)).subscribe((status) => {
this.isUnlocked = status === AuthenticationStatus.Unlocked;
});

this.authRequestAnsweringService.setupUnlockListenersForProcessingAuthRequests(this.destroy$);

this.authService.activeAccountStatus$
Expand Down Expand Up @@ -273,6 +289,25 @@ export class AppComponent implements OnInit, OnDestroy {
this.router.events.pipe(takeUntil(this.destroy$)).subscribe(async (event) => {
if (event instanceof NavigationEnd) {
const url = event.urlAfterRedirects || event.url || "";

// After unlock, if files were dropped while locked, redirect to the send form.
// This runs after the lock screen's post-unlock navigation completes to avoid a race.
if (
this.isUnlocked &&
!url.startsWith("/add-send") &&
this.pendingDragDropService.hasPending()
) {
void this.router.navigate(["/add-send"], {
replaceUrl: true,
queryParams: {
type: SendType.File,
dragDropFiles: Date.now().toString(),
isFolderMode: this.pendingDragDropFolderMode ? "true" : "false",
},
});
return;
}

if (url.startsWith("/tabs/")) {
await this.cipherService.setAddEditCipherInfo(null, this.activeUserId);
}
Expand Down Expand Up @@ -307,6 +342,36 @@ export class AppComponent implements OnInit, OnDestroy {
return outlet.activatedRouteData.elevation;
}

private async setupDragDropListeners(): Promise<void> {
if (BrowserPopupUtils.inPopup(window)) {
return;
}

const enabled = await this.configService.getFeatureFlag(FeatureFlag.SendFolder);
if (!enabled) {
return;
}

setupAppDragDrop(
(active) => this.isDragOverApp.set(active),
(result) => {
this.pendingDragDropService.setFiles(result.files, result.folderName);
this.pendingDragDropFolderMode = result.folderName != null;

if (this.isUnlocked) {
void this.router.navigate(["/add-send"], {
replaceUrl: true,
queryParams: {
type: SendType.File,
dragDropFiles: Date.now().toString(),
isFolderMode: this.pendingDragDropFolderMode ? "true" : "false",
},
});
}
},
);
}

private async recordActivity() {
if (this.activeUserId == null) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Component, inject, viewChild } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormsModule } from "@angular/forms";
import { ActivatedRoute, Params, Router } from "@angular/router";
import { map, switchMap } from "rxjs";
import { switchMap } from "rxjs";

import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
Expand All @@ -29,6 +29,7 @@ import {
SendFormGenerationService,
SendFormMode,
SendFormModule,
PendingDragDropFilesService,
} from "@bitwarden/send-ui";

import { PopupBackBrowserDirective } from "../../../../platform/popup/layout/popup-back.directive";
Expand Down Expand Up @@ -109,6 +110,7 @@ export class SendAddEditComponent {
config: SendFormConfig;

private sendFormGenerationService = inject(SendFormGenerationService);
private pendingDragDropService = inject(PendingDragDropFilesService);
private readonly sendFormComponent = viewChild(SendFormComponent);

constructor(
Expand Down Expand Up @@ -189,8 +191,25 @@ export class SendAddEditComponent {
this.route.queryParams
.pipe(
takeUntilDestroyed(),
map((params) => new QueryParams(params)),
switchMap(async (params) => {
switchMap(async (queryParams) => {
// Handle drag-and-drop files from app-level drop zone
if (queryParams["dragDropFiles"]) {
const pending = this.pendingDragDropService.takeFiles();
if (pending != null && pending.files.length > 0) {
const config = await this.addEditFormConfigService.buildConfig(
"add",
undefined,
SendType.File,
);
config.preloadedFiles = pending.files;
if (pending.folderName != null) {
config.isFolderMode = true;
}
return config;
}
}

const params = new QueryParams(queryParams);
let mode: SendFormMode;
if (params.sendId == null) {
mode = "add";
Expand Down
62 changes: 61 additions & 1 deletion apps/desktop/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
NgZone,
OnDestroy,
OnInit,
signal,
Type,
ViewChild,
ViewContainerRef,
Expand Down Expand Up @@ -76,6 +77,7 @@ import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/res
import { DialogRef, DialogService, ToastOptions, ToastService } from "@bitwarden/components";
import { CredentialGeneratorHistoryDialogComponent } from "@bitwarden/generator-components";
import { KeyService, BiometricStateService } from "@bitwarden/key-management";
import { PendingDragDropFilesService, setupAppDragDrop } from "@bitwarden/send-ui";
import { AddEditFolderDialogComponent, AddEditFolderDialogResult } from "@bitwarden/vault";

import { DeleteAccountComponent } from "../auth/delete-account.component";
Expand Down Expand Up @@ -110,6 +112,27 @@ const SyncInterval = 6 * 60 * 60 * 1000; // 6 hours
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
</div>
<router-outlet *ngIf="!loading"></router-outlet>

@if (isDragOverApp()) {
<div
class="drag-drop-overlay"
style="
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
z-index: 9999;
pointer-events: none;
font-size: 1.2rem;
font-weight: 600;
color: white;
"
>
{{ "dropFilesToCreateSend" | i18n }}
</div>
}
</div>

<bit-toast-container></bit-toast-container>
Expand All @@ -130,6 +153,7 @@ export class AppComponent implements OnInit, OnDestroy {

showHeader$ = this.accountService.showHeader$;
loading = false;
readonly isDragOverApp = signal(false);

private lastActivity: Date = null;
private modal: ModalRef = null;
Expand All @@ -140,6 +164,7 @@ export class AppComponent implements OnInit, OnDestroy {
private processingPendingAuthRequests = false;
private shouldRerunAuthRequestProcessing = false;
private pendingSendService = inject(PendingSendService);
private pendingDragDropFilesService = inject(PendingDragDropFilesService);

private destroy$ = new Subject<void>();

Expand Down Expand Up @@ -192,7 +217,9 @@ export class AppComponent implements OnInit, OnDestroy {
this.destroyRef.onDestroy(() => langSubscription.unsubscribe());
}

ngOnInit() {
async ngOnInit() {
await this.setupDragDropListeners();

this.accountService.activeAccount$.pipe(takeUntil(this.destroy$)).subscribe((account) => {
this.activeUserId = account?.id;
});
Expand Down Expand Up @@ -928,6 +955,39 @@ export class AppComponent implements OnInit, OnDestroy {
});
}

private async setupDragDropListeners(): Promise<void> {
const enabled = await this.configService.getFeatureFlag(FeatureFlag.SendFolder);
if (!enabled) {
return;
}

setupAppDragDrop(
(active) => this.isDragOverApp.set(active),
(result) => {
this.pendingDragDropFilesService.setFiles(result.files, result.folderName);
void this.handlePendingDragDrop();
},
);
}

private async handlePendingDragDrop(): Promise<void> {
if (!this.pendingDragDropFilesService.hasPending()) {
return;
}

const authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
if (authStatus !== AuthenticationStatus.Unlocked) {
// Files stay in PendingDragDropFilesService β€” the vault route guard will redirect after unlock
return;
}

await this.router.navigate(["/send"], {
queryParams: {
dragDropFiles: Date.now().toString(),
},
});
}

private async deleteAccount() {
const userIsManaged = await firstValueFrom(
this.accountService.activeAccount$.pipe(
Expand Down
15 changes: 14 additions & 1 deletion apps/desktop/src/app/tools/send/pending-send.guard.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";

import { PendingDragDropFilesService } from "@bitwarden/send-ui";

import { PendingSendService } from "./pending-send.service";

/**
* Route guard that redirects from `/vault` to `/send` when there are
* pending file paths from the Windows Explorer "Create Send" context menu.
* pending file paths from the Windows Explorer "Create Send" context menu
* or pending File objects from drag-and-drop.
*
* On cold start, pulls any --send-path arguments from the main process
* via IPC (the push-based deep link may not have arrived yet).
Expand All @@ -14,6 +17,7 @@ import { PendingSendService } from "./pending-send.service";
*/
export const pendingSendGuard: CanActivateFn = async () => {
const pendingSendService = inject(PendingSendService);
const pendingDragDropService = inject(PendingDragDropFilesService);
const router = inject(Router);

// Pull any paths the main process collected from --send-path arguments.
Expand All @@ -24,6 +28,15 @@ export const pendingSendGuard: CanActivateFn = async () => {
pendingSendService.addPaths(mainProcessPaths);
}

// Check for drag-and-drop files first (in-memory File objects)
if (pendingDragDropService.hasPending()) {
return router.createUrlTree(["/send"], {
queryParams: {
dragDropFiles: "true",
},
});
}

if (!pendingSendService.hasPending()) {
return true;
}
Expand Down
Loading
Loading