diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index f1f2aa5c6..450cd4ca7 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -1,5 +1,4 @@ import ch.qos.logback.classic.spi.Configurator; -import org.cryptomator.networking.SSLContextWithPKCS12TrustStore; import org.cryptomator.common.locationpresets.DropboxLinuxLocationPresetsProvider; import org.cryptomator.common.locationpresets.DropboxMacLocationPresetsProvider; import org.cryptomator.common.locationpresets.DropboxWindowsLocationPresetsProvider; @@ -14,11 +13,12 @@ import org.cryptomator.common.locationpresets.OneDriveLinuxLocationPresetsProvid import org.cryptomator.common.locationpresets.OneDriveMacLocationPresetsProvider; import org.cryptomator.common.locationpresets.OneDriveWindowsLocationPresetsProvider; import org.cryptomator.common.locationpresets.PCloudLocationPresetsProvider; -import org.cryptomator.networking.SSLContextWithMacKeychain; -import org.cryptomator.networking.SSLContextProvider; -import org.cryptomator.networking.SSLContextWithWindowsCertStore; import org.cryptomator.integrations.tray.TrayMenuController; import org.cryptomator.logging.LogbackConfiguratorFactory; +import org.cryptomator.networking.SSLContextProvider; +import org.cryptomator.networking.SSLContextWithMacKeychain; +import org.cryptomator.networking.SSLContextWithPKCS12TrustStore; +import org.cryptomator.networking.SSLContextWithWindowsCertStore; import org.cryptomator.ui.traymenu.AwtTrayMenuController; open module org.cryptomator.desktop { diff --git a/src/main/java/org/cryptomator/common/EventMap.java b/src/main/java/org/cryptomator/common/EventMap.java deleted file mode 100644 index 468ad9cba..000000000 --- a/src/main/java/org/cryptomator/common/EventMap.java +++ /dev/null @@ -1,162 +0,0 @@ -package org.cryptomator.common; - -import org.cryptomator.cryptofs.event.BrokenDirFileEvent; -import org.cryptomator.cryptofs.event.BrokenFileNodeEvent; -import org.cryptomator.cryptofs.event.ConflictResolutionFailedEvent; -import org.cryptomator.cryptofs.event.ConflictResolvedEvent; -import org.cryptomator.cryptofs.event.DecryptionFailedEvent; -import org.cryptomator.cryptofs.event.FileIsInUseEvent; -import org.cryptomator.cryptofs.event.FilesystemEvent; -import org.cryptomator.event.VaultEvent; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import javax.inject.Inject; -import javax.inject.Singleton; -import javafx.beans.InvalidationListener; -import javafx.collections.FXCollections; -import javafx.collections.MapChangeListener; -import javafx.collections.ObservableMap; -import java.nio.file.Path; -import java.util.Collection; -import java.util.Comparator; -import java.util.Map; -import java.util.Set; - -/** - * Map containing {@link VaultEvent}s. - * The map is keyed by the ciphertext path of the affected resource _and_ the {@link FilesystemEvent}s class in order to group same events - *

- * Use {@link EventMap#put(VaultEvent)} to add an element and {@link EventMap#remove(VaultEvent)} to remove it. - *

- * The map is size restricted to {@value MAX_SIZE} elements. If a _new_ element (i.e. not already present) is added, the least recently added is removed. - */ -@Singleton -public class EventMap implements ObservableMap { - - private static final int MAX_SIZE = 300; - - public record EventKey(Path ciphertextPath, Class c) {} - - private final ObservableMap delegate; - - @Inject - public EventMap() { - delegate = FXCollections.observableHashMap(); - } - - @Override - public void addListener(MapChangeListener mapChangeListener) { - delegate.addListener(mapChangeListener); - } - - @Override - public void removeListener(MapChangeListener mapChangeListener) { - delegate.removeListener(mapChangeListener); - } - - @Override - public int size() { - return delegate.size(); - } - - @Override - public boolean isEmpty() { - return delegate.isEmpty(); - } - - @Override - public boolean containsKey(Object key) { - return delegate.containsKey(key); - } - - @Override - public boolean containsValue(Object value) { - return delegate.containsValue(value); - } - - @Override - public VaultEvent get(Object key) { - return delegate.get(key); - } - - @Override - public @Nullable VaultEvent put(EventKey key, VaultEvent value) { - return delegate.put(key, value); - } - - @Override - public VaultEvent remove(Object key) { - return delegate.remove(key); - } - - @Override - public void putAll(@NotNull Map m) { - delegate.putAll(m); - } - - @Override - public void clear() { - delegate.clear(); - } - - @Override - public @NotNull Set keySet() { - return delegate.keySet(); - } - - @Override - public @NotNull Collection values() { - return delegate.values(); - } - - @Override - public @NotNull Set> entrySet() { - return delegate.entrySet(); - } - - @Override - public void addListener(InvalidationListener invalidationListener) { - delegate.addListener(invalidationListener); - } - - @Override - public void removeListener(InvalidationListener invalidationListener) { - delegate.removeListener(invalidationListener); - } - - public synchronized void put(VaultEvent e) { - //compute key - var key = computeKey(e.actualEvent()); - //if-else - var nullOrEntry = delegate.get(key); - if (nullOrEntry == null) { - if (size() == MAX_SIZE) { - delegate.entrySet().stream() // - .min(Comparator.comparing(entry -> entry.getValue().actualEvent().getTimestamp())) // - .ifPresent(oldestEntry -> delegate.remove(oldestEntry.getKey())); - } - delegate.put(key, e); - } else { - delegate.put(key, nullOrEntry.incrementCount(e.actualEvent())); - } - } - - public synchronized VaultEvent remove(VaultEvent similar) { - //compute key - var key = computeKey(similar.actualEvent()); - return this.remove(key); - } - - private EventKey computeKey(FilesystemEvent e) { - var p = switch (e) { - case DecryptionFailedEvent(_, Path ciphertextPath, _) -> ciphertextPath; - case ConflictResolvedEvent(_, _, _, _, Path resolvedCiphertext) -> resolvedCiphertext; - case ConflictResolutionFailedEvent(_, _, Path conflictingCiphertext, _) -> conflictingCiphertext; - case BrokenDirFileEvent(_, Path ciphertext) -> ciphertext; - case BrokenFileNodeEvent(_, _, Path ciphertext) -> ciphertext; - case FileIsInUseEvent(_, _, Path ciphertext, _, _, _) -> ciphertext; - }; - return new EventKey(p, e.getClass()); - } -} diff --git a/src/main/java/org/cryptomator/common/vaults/Vault.java b/src/main/java/org/cryptomator/common/vaults/Vault.java index 758ed99a3..b5158411b 100644 --- a/src/main/java/org/cryptomator/common/vaults/Vault.java +++ b/src/main/java/org/cryptomator/common/vaults/Vault.java @@ -24,6 +24,7 @@ import org.cryptomator.cryptolib.api.CryptoException; import org.cryptomator.cryptolib.api.MasterkeyLoader; import org.cryptomator.cryptolib.api.MasterkeyLoadingFailedException; import org.cryptomator.event.FileSystemEventAggregator; +import org.cryptomator.event.NotificationManager; import org.cryptomator.integrations.mount.MountFailedException; import org.cryptomator.integrations.mount.Mountpoint; import org.cryptomator.integrations.mount.UnmountFailedException; @@ -79,6 +80,7 @@ public class Vault { private final Mounter mounter; private final Settings settings; private final FileSystemEventAggregator fileSystemEventAggregator; + private final NotificationManager notificationManager; private final BooleanProperty showingStats; private final AtomicReference mountHandle = new AtomicReference<>(null); @@ -91,7 +93,8 @@ public class Vault { @Named("lastKnownException") ObjectProperty lastKnownException, // VaultStats stats, // Mounter mounter, Settings settings, // - FileSystemEventAggregator fileSystemEventAggregator) { + FileSystemEventAggregator fileSystemEventAggregator, // + NotificationManager notificationManager) { this.vaultSettings = vaultSettings; this.configCache = configCache; this.cryptoFileSystem = cryptoFileSystem; @@ -110,6 +113,7 @@ public class Vault { this.mounter = mounter; this.settings = settings; this.fileSystemEventAggregator = fileSystemEventAggregator; + this.notificationManager = notificationManager; this.showingStats = new SimpleBooleanProperty(false); this.quickAccessEntry = new AtomicReference<>(null); } @@ -266,6 +270,7 @@ public class Vault { private void consumeVaultEvent(FilesystemEvent e) { fileSystemEventAggregator.put(this, e); + notificationManager.offer(this, e); } // ****************************************************************************** diff --git a/src/main/java/org/cryptomator/event/NotificationManager.java b/src/main/java/org/cryptomator/event/NotificationManager.java new file mode 100644 index 000000000..fe6020f7d --- /dev/null +++ b/src/main/java/org/cryptomator/event/NotificationManager.java @@ -0,0 +1,82 @@ +package org.cryptomator.event; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import org.cryptomator.common.vaults.Vault; +import org.cryptomator.cryptofs.event.FilesystemEvent; + +import javax.inject.Inject; +import javax.inject.Singleton; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Manager for notifications. + *

+ * To add (filesystem) events, use method {@link #offer(Vault, FilesystemEvent)}. If the input event is eligible, it is added to an internal queue. + * An event is eligible, if + *

+ * + * @see org.cryptomator.ui.fxapp.FxNotificationManager + */ +@Singleton +public class NotificationManager { + + private static final int DEBOUNCE_THRESHOLD_SECONDS = 5; + + private final Cache debounceCache; + private final ConcurrentLinkedQueue pendingEvents; + + @Inject + public NotificationManager() { + debounceCache = Caffeine.newBuilder().expireAfterWrite(Duration.ofSeconds(DEBOUNCE_THRESHOLD_SECONDS)).build(); + pendingEvents = new ConcurrentLinkedQueue<>(); + } + + /** + * Offers the given filesystem event to the notification manager. + * + * @param v The vault where the filesystem event happened + * @param e the actual filesystem event + * @return {@code true} if the filesystem event is accepted, otherwise {@code false}. + */ + public boolean offer(Vault v, FilesystemEvent e) { + return switch (e) { + //example: case BrokenFileNodeEvent bfne -> addEvent(v, bfne.ciphertextPath(), bfne); + default -> false; + }; + } + + boolean addEvent(Vault v, Path keyPath, FilesystemEvent e) { + var key = new FSEventBucket(v, keyPath, e.getClass()); + var isAdded = new AtomicBoolean(false); + debounceCache.asMap().computeIfAbsent(key, _ -> { + synchronized (this) { + pendingEvents.add(new VaultEvent(v, e)); + isAdded.set(true); + } + return e; + }); + return isAdded.get(); + } + + /** + * Adds all events to the target list and clears afterward the pending-event-queue + * + * @param target list where the filesystem events are copied to + * @return {@code true}, if elements were copied + */ + public boolean addTo(List target) { + synchronized (this) { + var result = target.addAll(pendingEvents); + pendingEvents.clear(); + return result; + } + } +} diff --git a/src/main/java/org/cryptomator/event/VaultEvent.java b/src/main/java/org/cryptomator/event/VaultEvent.java index 8b31747cf..1095c812b 100644 --- a/src/main/java/org/cryptomator/event/VaultEvent.java +++ b/src/main/java/org/cryptomator/event/VaultEvent.java @@ -3,25 +3,6 @@ package org.cryptomator.event; import org.cryptomator.common.vaults.Vault; import org.cryptomator.cryptofs.event.FilesystemEvent; -import java.time.Instant; +public record VaultEvent(Vault v, FilesystemEvent actualEvent) { -public record VaultEvent(Vault v, FilesystemEvent actualEvent, int count) implements Comparable { - - public VaultEvent(Vault v, FilesystemEvent actualEvent) { - this(v, actualEvent, 1); - } - - @Override - public int compareTo(VaultEvent other) { - var timeResult = actualEvent.getTimestamp().compareTo(other.actualEvent().getTimestamp()); - if(timeResult != 0) { - return timeResult; - } else { - return this.equals(other) ? 0 : this.actualEvent.getClass().getName().compareTo(other.actualEvent.getClass().getName()); - } - } - - public VaultEvent incrementCount(FilesystemEvent update) { - return new VaultEvent(v, update, count+1); - } } diff --git a/src/main/java/org/cryptomator/ui/common/FxmlFile.java b/src/main/java/org/cryptomator/ui/common/FxmlFile.java index 68607808d..7278ddb99 100644 --- a/src/main/java/org/cryptomator/ui/common/FxmlFile.java +++ b/src/main/java/org/cryptomator/ui/common/FxmlFile.java @@ -57,7 +57,8 @@ public enum FxmlFile { UNLOCK_SUCCESS("/fxml/unlock_success.fxml"), // VAULT_OPTIONS("/fxml/vault_options.fxml"), // VAULT_STATISTICS("/fxml/stats.fxml"), // - WRONGFILEALERT("/fxml/wrongfilealert.fxml"); + WRONGFILEALERT("/fxml/wrongfilealert.fxml"), + NOTIFICATION("/fxml/notification.fxml"); private final String ressourcePathString; diff --git a/src/main/java/org/cryptomator/ui/common/SystemBarUtil.java b/src/main/java/org/cryptomator/ui/common/SystemBarUtil.java new file mode 100644 index 000000000..9faaa60db --- /dev/null +++ b/src/main/java/org/cryptomator/ui/common/SystemBarUtil.java @@ -0,0 +1,56 @@ +package org.cryptomator.ui.common; + +import javafx.stage.Screen; + +/** + * Utility class providing methods regarding the OS bar. + */ +public class SystemBarUtil { + + public enum Placement { + /** + * OS Bar placed at the left screen edge + */ + LEFT, + /** + * OS Bar placed at the top screen edge + */ + TOP, + /** + * OS Bar placed at the right screen edge + */ + RIGHT, + /** + * OS Bar placed at the bottom screen edge + */ + BOTTOM; + } + + /** + * Determines the placement of the OS bar on the given screen. + *

+ * Assuming the OS bar fills one screen edge completely, + * this method determines that screen edge by comparing the actual screen bounds with the visual ones. + *

+ * If the screen does not have a system bar, the bottom placement is returned. + * If the screen does have multiple system bars, the first in following priority is returned: + * LEFT, TOP, RIGHT, BOTTOM. + * + * @param screen a {@link Screen} where an OS bar exists + * @return {@link Placement} indicating the screen edge. + */ + public static Placement getPlacementOfSystembar(Screen screen) { + var bounds = screen.getBounds(); + var vBounds = screen.getVisualBounds(); + //assumption: the system bar fills a whole screen side + if (bounds.getMinX() != vBounds.getMinX()) { + return Placement.LEFT; + } else if (bounds.getMinY() != vBounds.getMinY()) { + return Placement.TOP; + } else if (bounds.getMaxX() != vBounds.getMaxX()) { + return Placement.RIGHT; + } else { + return Placement.BOTTOM; + } + } +} diff --git a/src/main/java/org/cryptomator/ui/controls/FontAwesome5Icon.java b/src/main/java/org/cryptomator/ui/controls/FontAwesome5Icon.java index 348b3a26b..b479a7f14 100644 --- a/src/main/java/org/cryptomator/ui/controls/FontAwesome5Icon.java +++ b/src/main/java/org/cryptomator/ui/controls/FontAwesome5Icon.java @@ -12,6 +12,8 @@ public enum FontAwesome5Icon { CARET_DOWN("\uF0D7"), // CARET_RIGHT("\uF0Da"), // CHECK("\uF00C"), // + CHEVRON_LEFT("\uF053"), // + CHEVRON_RIGHT("\uF054"), // CLOCK("\uF017"), // CLIPBOARD("\uF328"), // COG("\uF013"), // diff --git a/src/main/java/org/cryptomator/ui/fxapp/FxApplication.java b/src/main/java/org/cryptomator/ui/fxapp/FxApplication.java index ccc0684af..7fb5b523b 100644 --- a/src/main/java/org/cryptomator/ui/fxapp/FxApplication.java +++ b/src/main/java/org/cryptomator/ui/fxapp/FxApplication.java @@ -30,9 +30,20 @@ public class FxApplication { private final FxApplicationTerminator applicationTerminator; private final AutoUnlocker autoUnlocker; private final FxFSEventList fxFSEventList; + private final FxNotificationManager notificationManager; @Inject - FxApplication(@Named("startupTime") long startupTime, Environment environment, Settings settings, AppLaunchEventHandler launchEventHandler, Lazy trayMenu, FxApplicationWindows appWindows, FxApplicationStyle applicationStyle, FxApplicationTerminator applicationTerminator, AutoUnlocker autoUnlocker, FxFSEventList fxFSEventList) { + FxApplication(@Named("startupTime") long startupTime, // + Environment environment, // + Settings settings, // + AppLaunchEventHandler launchEventHandler, // + Lazy trayMenu, // + FxApplicationWindows appWindows, // + FxApplicationStyle applicationStyle, // + FxApplicationTerminator applicationTerminator, // + AutoUnlocker autoUnlocker, // + FxFSEventList fxFSEventList, // + FxNotificationManager notificationManager) { this.startupTime = startupTime; this.environment = environment; this.settings = settings; @@ -43,6 +54,7 @@ public class FxApplication { this.applicationTerminator = applicationTerminator; this.autoUnlocker = autoUnlocker; this.fxFSEventList = fxFSEventList; + this.notificationManager = notificationManager; } public void start() { @@ -88,6 +100,7 @@ public class FxApplication { launchEventHandler.startHandlingLaunchEvents(); fxFSEventList.schedulePollForUpdates(); + notificationManager.schedulePollForUpdates(); autoUnlocker.tryUnlockForTimespan(2, TimeUnit.MINUTES); } diff --git a/src/main/java/org/cryptomator/ui/fxapp/FxApplicationModule.java b/src/main/java/org/cryptomator/ui/fxapp/FxApplicationModule.java index 70319df5b..90a48039a 100644 --- a/src/main/java/org/cryptomator/ui/fxapp/FxApplicationModule.java +++ b/src/main/java/org/cryptomator/ui/fxapp/FxApplicationModule.java @@ -13,6 +13,7 @@ import org.cryptomator.ui.eventview.EventViewComponent; import org.cryptomator.ui.health.HealthCheckComponent; import org.cryptomator.ui.lock.LockComponent; import org.cryptomator.ui.mainwindow.MainWindowComponent; +import org.cryptomator.ui.notification.NotificationComponent; import org.cryptomator.ui.preferences.PreferencesComponent; import org.cryptomator.ui.quit.QuitComponent; import org.cryptomator.ui.recoverykey.RecoveryKeyComponent; @@ -39,7 +40,8 @@ import java.io.InputStream; UpdateReminderComponent.class, // ShareVaultComponent.class, // EventViewComponent.class, // - RecoveryKeyComponent.class}) + RecoveryKeyComponent.class, // + NotificationComponent.class }) abstract class FxApplicationModule { private static Image createImageFromResource(String resourceName) throws IOException { diff --git a/src/main/java/org/cryptomator/ui/fxapp/FxApplicationWindows.java b/src/main/java/org/cryptomator/ui/fxapp/FxApplicationWindows.java index c8a870fd8..aadfb8a2b 100644 --- a/src/main/java/org/cryptomator/ui/fxapp/FxApplicationWindows.java +++ b/src/main/java/org/cryptomator/ui/fxapp/FxApplicationWindows.java @@ -11,6 +11,7 @@ import org.cryptomator.ui.error.ErrorComponent; import org.cryptomator.ui.eventview.EventViewComponent; import org.cryptomator.ui.lock.LockComponent; import org.cryptomator.ui.mainwindow.MainWindowComponent; +import org.cryptomator.ui.notification.NotificationComponent; import org.cryptomator.ui.preferences.PreferencesComponent; import org.cryptomator.ui.preferences.SelectedPreferencesTab; import org.cryptomator.ui.quit.QuitComponent; @@ -54,6 +55,7 @@ public class FxApplicationWindows { private final LockComponent.Factory lockWorkflowFactory; private final ErrorComponent.Factory errorWindowFactory; private final Lazy eventViewWindow; + private final NotificationComponent.Factory notificationWindow; private final ExecutorService executor; private final VaultOptionsComponent.Factory vaultOptionsWindow; private final ShareVaultComponent.Factory shareVaultWindow; @@ -73,6 +75,7 @@ public class FxApplicationWindows { VaultOptionsComponent.Factory vaultOptionsWindow, // ShareVaultComponent.Factory shareVaultWindow, // Lazy eventViewWindow, // + NotificationComponent.Factory notificationWindow, ExecutorService executor, // Dialogs dialogs) { this.primaryStage = primaryStage; @@ -85,6 +88,7 @@ public class FxApplicationWindows { this.lockWorkflowFactory = lockWorkflowFactory; this.errorWindowFactory = errorWindowFactory; this.eventViewWindow = eventViewWindow; + this.notificationWindow = notificationWindow; this.executor = executor; this.vaultOptionsWindow = vaultOptionsWindow; this.shareVaultWindow = shareVaultWindow; @@ -193,6 +197,10 @@ public class FxApplicationWindows { return CompletableFuture.supplyAsync(() -> eventViewWindow.get().showEventViewerWindow(), Platform::runLater).whenComplete(this::reportErrors); } + public CompletionStage showNotification() { + return CompletableFuture.supplyAsync(() -> notificationWindow.create().showNotification(), Platform::runLater).whenComplete(this::reportErrors); + } + /** * Displays the generic error scene in the given window. * diff --git a/src/main/java/org/cryptomator/ui/fxapp/FxNotificationManager.java b/src/main/java/org/cryptomator/ui/fxapp/FxNotificationManager.java new file mode 100644 index 000000000..7be86fc50 --- /dev/null +++ b/src/main/java/org/cryptomator/ui/fxapp/FxNotificationManager.java @@ -0,0 +1,57 @@ +package org.cryptomator.ui.fxapp; + +import org.cryptomator.event.NotificationManager; +import org.cryptomator.event.VaultEvent; + +import javax.inject.Inject; +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Notification manager inside the UI domain. + *

+ * Polls the {@link NotificationManager} for pending events every {@value POLL_INTERVAL_SECONDS } seconds and + * triggers the notification window display when events are available. + * Returns an observable list of events requiring a user notification with {@link #getEventsRequiringNotification()}. + * + * @see NotificationManager + */ +@FxApplicationScoped +public class FxNotificationManager { + + private static final int POLL_INTERVAL_SECONDS = 1; + + private final NotificationManager notificationManager; + private final ScheduledExecutorService scheduler; + private final FxApplicationWindows applicationWindows; + private final ObservableList eventsRequiringNotification; + + @Inject + public FxNotificationManager(NotificationManager notificationManager, ScheduledExecutorService scheduler, FxApplicationWindows applicationWindows) { + this.notificationManager = notificationManager; + this.scheduler = scheduler; + this.applicationWindows = applicationWindows; + this.eventsRequiringNotification = FXCollections.observableArrayList(); + } + + public void schedulePollForUpdates() { + scheduler.scheduleAtFixedRate(this::checkForPendingNotifications, 0, POLL_INTERVAL_SECONDS, TimeUnit.SECONDS); + } + + private void checkForPendingNotifications() { + Platform.runLater(() -> { + if (notificationManager.addTo(eventsRequiringNotification)) { + applicationWindows.showNotification(); + } + }); + + } + + public ObservableList getEventsRequiringNotification() { + return eventsRequiringNotification; + } + +} diff --git a/src/main/java/org/cryptomator/ui/mainwindow/VaultListController.java b/src/main/java/org/cryptomator/ui/mainwindow/VaultListController.java index f25528498..f3c3ccb83 100644 --- a/src/main/java/org/cryptomator/ui/mainwindow/VaultListController.java +++ b/src/main/java/org/cryptomator/ui/mainwindow/VaultListController.java @@ -15,8 +15,8 @@ import org.cryptomator.ui.addvaultwizard.AddVaultWizardComponent; import org.cryptomator.ui.common.FxController; import org.cryptomator.ui.common.VaultService; import org.cryptomator.ui.dialogs.Dialogs; -import org.cryptomator.ui.fxapp.FxFSEventList; import org.cryptomator.ui.fxapp.FxApplicationWindows; +import org.cryptomator.ui.fxapp.FxFSEventList; import org.cryptomator.ui.preferences.SelectedPreferencesTab; import org.cryptomator.ui.recoverykey.RecoveryKeyComponent; import org.slf4j.Logger; @@ -139,8 +139,8 @@ public class VaultListController implements FxController { vaultList.setItems(vaults); vaultList.setCellFactory(cellFactory); - vaultList.prefHeightProperty().bind( - vaultList.fixedCellSizeProperty().multiply(Bindings.size(vaultList.getItems())) + vaultList.prefHeightProperty().bind( // + vaultList.fixedCellSizeProperty().multiply(Bindings.size(vaultList.getItems())) // ); selectedVault.bind(vaultList.getSelectionModel().selectedItemProperty()); @@ -157,11 +157,11 @@ public class VaultListController implements FxController { //unlock vault on double click vaultList.addEventFilter(MouseEvent.MOUSE_CLICKED, click -> { if (click.getClickCount() >= 2) { - Optional.ofNullable(selectedVault.get()) - .filter(Vault::isLocked) + Optional.ofNullable(selectedVault.get()) // + .filter(Vault::isLocked) // .ifPresent(vault -> appWindows.startUnlockWorkflow(vault, mainWindow)); - Optional.ofNullable(selectedVault.get()) - .filter(Vault::isUnlocked) + Optional.ofNullable(selectedVault.get()) // + .filter(Vault::isUnlocked) // .ifPresent(vaultService::reveal); } }); diff --git a/src/main/java/org/cryptomator/ui/notification/NotificationComponent.java b/src/main/java/org/cryptomator/ui/notification/NotificationComponent.java new file mode 100644 index 000000000..3ebcdf4ab --- /dev/null +++ b/src/main/java/org/cryptomator/ui/notification/NotificationComponent.java @@ -0,0 +1,35 @@ +package org.cryptomator.ui.notification; + +import dagger.Lazy; +import dagger.Subcomponent; +import org.cryptomator.ui.common.FxmlFile; +import org.cryptomator.ui.common.FxmlScene; + +import javafx.scene.Scene; +import javafx.stage.Stage; + +@NotificationScoped +@Subcomponent(modules = {NotificationModule.class}) +public interface NotificationComponent { + + @NotificationWindow + Stage window(); + + @FxmlScene(FxmlFile.NOTIFICATION) + Lazy scene(); + + default Stage showNotification() { + var window = window(); + window.setScene(scene().get()); + window.sizeToScene(); + window.show(); + window.requestFocus(); + return window; + } + + @Subcomponent.Factory + interface Factory { + NotificationComponent create(); + } + +} diff --git a/src/main/java/org/cryptomator/ui/notification/NotificationController.java b/src/main/java/org/cryptomator/ui/notification/NotificationController.java new file mode 100644 index 000000000..0fc24621c --- /dev/null +++ b/src/main/java/org/cryptomator/ui/notification/NotificationController.java @@ -0,0 +1,186 @@ +package org.cryptomator.ui.notification; + +import org.cryptomator.event.VaultEvent; +import org.cryptomator.ui.common.FxController; +import org.cryptomator.ui.fxapp.FxNotificationManager; + +import javax.inject.Inject; +import javafx.beans.binding.Bindings; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.beans.property.SimpleListProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.beans.value.ObservableStringValue; +import javafx.beans.value.ObservableValue; +import javafx.fxml.FXML; +import javafx.stage.Stage; +import java.util.Objects; +import java.util.concurrent.ExecutorService; + +@NotificationScoped +public class NotificationController implements FxController { + + private static final String BUG_MSG = "IF YOU SEE THIS MESSAGE, PLEASE CONTACT THE DEVELOPERS OF CRYPTOMATOR ABOUT A BUG IN THE NOTIFICATION DISPLAY"; + + private final Stage window; + private final SimpleListProperty events; + private final IntegerProperty selectionIndex; + private final ObservableStringValue paging; + private final ObjectProperty selectedEvent; + private final ObservableValue singleEvent; + private final StringProperty vaultName; + private final StringProperty message; + private final StringProperty description; + private final StringProperty actionText; + private final ExecutorService executorService; + + @Inject + public NotificationController(@NotificationWindow Stage window, FxNotificationManager notificationManager, ExecutorService executorService) { + this.window = window; + this.events = new SimpleListProperty<>(notificationManager.getEventsRequiringNotification()); + this.selectionIndex = new SimpleIntegerProperty(-1); + this.selectedEvent = new SimpleObjectProperty<>(); + this.singleEvent = events.sizeProperty().map(size -> size.intValue() == 1); + this.paging = Bindings.createStringBinding(() -> selectionIndex.get() + 1 + "/" + events.size(), selectionIndex, events); + this.vaultName = new SimpleStringProperty(); + this.message = new SimpleStringProperty(); + this.description = new SimpleStringProperty(); + this.actionText = new SimpleStringProperty(); + this.executorService = executorService; + } + + @FXML + public void initialize() { + selectionIndex.addListener((_, _, n) -> { + if (!events.isEmpty()) { + selectedEvent.setValue(events.get(n.intValue())); + } + }); + selectedEvent.addListener(this::selectTexts); + + selectionIndex.setValue(0); + } + + //TODO: Translations! + private void selectTexts(ObservableValue observable, VaultEvent oldEvent, VaultEvent newEvent) { + if (newEvent == null) { + vaultName.set(""); + message.set("NO CONTENT"); + description.set(BUG_MSG); + actionText.set(null); + return; + } + + switch (newEvent.actualEvent()) { + default -> { + vaultName.set(newEvent.v().getDisplayName()); + message.set("NO CONTENT"); + description.set(BUG_MSG); + actionText.set(null); + } + } + } + + + @FXML + public void processSelectedEvent() { + try { + var ev = selectedEvent.get(); + switch (ev.actualEvent()) { + //TODO: executorService.submit(callback.action()); + default -> { + } //normally nothing + } + } finally { + removeSelectedEvent(); + } + } + + private void removeSelectedEvent() { + int i = selectionIndex.get(); + events.remove(i); + if (events.isEmpty()) { + close(); //no more events + } else if (events.size() == i) { + selectionIndex.set(i - 1); //triggers event update + } else { + selectedEvent.set(events.get(i)); + } + } + + @FXML + public void previousNotification() { + int i = selectionIndex.get(); + if (i != 0) { + selectionIndex.set(i - 1); + } + } + + @FXML + public void nextNotification() { + int i = selectionIndex.get(); + if (i != events.size() - 1) { + selectionIndex.set(i + 1); + } + } + + @FXML + public void close() { + events.clear(); + window.close(); + } + + + //FXML bindings + public ObservableValue vaultNameProperty() { + return vaultName; + } + + public String getVaultName() { + return vaultName.get(); + } + + public ObservableValue messageProperty() { + return message; + } + + public String getMessage() { + return message.get(); + } + + public ObservableValue descriptionProperty() { + return description; + } + + public String getDescription() { + return description.get(); + } + + public StringProperty actionTextProperty() { + return actionText; + } + + public String getActionText() { + return Objects.requireNonNullElse(actionText.get(), ""); + } + + public ObservableStringValue pagingProperty() { + return paging; + } + + public String getPaging() { + return paging.get(); + } + + public ObservableValue singleEventProperty() { + return singleEvent; + } + + public boolean isSingleEvent() { + return singleEvent.getValue(); + } + +} diff --git a/src/main/java/org/cryptomator/ui/notification/NotificationModule.java b/src/main/java/org/cryptomator/ui/notification/NotificationModule.java new file mode 100644 index 000000000..64895a40c --- /dev/null +++ b/src/main/java/org/cryptomator/ui/notification/NotificationModule.java @@ -0,0 +1,99 @@ +package org.cryptomator.ui.notification; + +import dagger.Binds; +import dagger.Module; +import dagger.Provides; +import dagger.multibindings.IntoMap; +import org.apache.commons.lang3.SystemUtils; +import org.cryptomator.ui.common.DefaultSceneFactory; +import org.cryptomator.ui.common.FxController; +import org.cryptomator.ui.common.FxControllerKey; +import org.cryptomator.ui.common.FxmlFile; +import org.cryptomator.ui.common.FxmlLoaderFactory; +import org.cryptomator.ui.common.FxmlScene; +import org.cryptomator.ui.common.StageInitializer; +import org.cryptomator.ui.common.SystemBarUtil; + +import javax.inject.Provider; +import javafx.scene.Scene; +import javafx.stage.Modality; +import javafx.stage.Screen; +import javafx.stage.Stage; +import javafx.stage.StageStyle; +import java.util.Map; +import java.util.ResourceBundle; + +@Module +abstract class NotificationModule { + + @Provides + @NotificationWindow + @NotificationScoped + static Stage provideStage(StageInitializer initializer) { + Stage stage = new Stage(StageStyle.TRANSPARENT); + stage.setTitle("Filesystem notification"); //TODO: translate + stage.setResizable(false); + stage.initModality(Modality.NONE); + stage.setAlwaysOnTop(true); + initializer.accept(stage); + stage.setOnShown(_ -> placeWindow(stage)); + return stage; + } + + /** + * Places the notification window on the screen according to some heuristic based on operating system and system bar placement. + *

+ * On macOS, the window is placed in the top-right corner of the primary screen, following platform conventions. + * On other operating systems, the window placement depends on the location of the system bar: + *

    + *
  • If the system bar is at the top, the window is centered horizontally at the top of the screen.
  • + *
  • Otherwise (e.g., system bar at the bottom or elsewhere), the window is placed in the bottom-right corner.
  • + *
+ *

+ * The method uses the visual bounds of the primary screen to avoid overlapping with system UI elements. + * Assumes the window size has already been set before calling this method. + * + * @param window the Stage representing the notification window to be placed + */ + static void placeWindow(Stage window) { + var screen = Screen.getPrimary(); + var vBounds = screen.getVisualBounds(); + if (SystemUtils.IS_OS_MAC) { //place to right top + window.setX(vBounds.getMaxX() - window.getWidth()); + window.setY(vBounds.getMinY()); + } else { + switch (SystemBarUtil.getPlacementOfSystembar(screen)) { + case TOP -> { //place to middle top + window.setX(vBounds.getMinX() + (vBounds.getWidth() - window.getWidth()) / 2.0); + window.setY(vBounds.getMinY()); + } + default -> { //place to right bottom + window.setX(vBounds.getMaxX() - window.getWidth()); + window.setY(vBounds.getMaxY() - window.getHeight()); + } + } + } + } + + // javafx setup + + @Provides + @FxmlScene(FxmlFile.NOTIFICATION) + @NotificationScoped + static Scene provideNotificationScene(@NotificationWindow FxmlLoaderFactory fxmlLoaders) { + return fxmlLoaders.createScene(FxmlFile.NOTIFICATION); + } + + @Provides + @NotificationScoped + @NotificationWindow + static FxmlLoaderFactory provideFxmlLoaderFactory(Map, Provider> factories, DefaultSceneFactory sceneFactory, ResourceBundle resourceBundle) { + return new FxmlLoaderFactory(factories, sceneFactory, resourceBundle); + } + + @Binds + @IntoMap + @FxControllerKey(NotificationController.class) + abstract FxController bindNotificationController(NotificationController controller); + +} diff --git a/src/main/java/org/cryptomator/ui/notification/NotificationScoped.java b/src/main/java/org/cryptomator/ui/notification/NotificationScoped.java new file mode 100644 index 000000000..ca2b9d4b5 --- /dev/null +++ b/src/main/java/org/cryptomator/ui/notification/NotificationScoped.java @@ -0,0 +1,11 @@ +package org.cryptomator.ui.notification; + +import javax.inject.Scope; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Scope +@Documented +@Retention(RetentionPolicy.RUNTIME) +public @interface NotificationScoped {} diff --git a/src/main/java/org/cryptomator/ui/notification/NotificationWindow.java b/src/main/java/org/cryptomator/ui/notification/NotificationWindow.java new file mode 100644 index 000000000..5b541cee7 --- /dev/null +++ b/src/main/java/org/cryptomator/ui/notification/NotificationWindow.java @@ -0,0 +1,12 @@ +package org.cryptomator.ui.notification; + +import javax.inject.Qualifier; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Qualifier +@Documented +@Retention(RUNTIME) +public @interface NotificationWindow {} diff --git a/src/main/resources/css/dark_theme.css b/src/main/resources/css/dark_theme.css index fff15e834..a50bcd985 100644 --- a/src/main/resources/css/dark_theme.css +++ b/src/main/resources/css/dark_theme.css @@ -113,6 +113,11 @@ -fx-font-size: 1.2em; } +.label-window-title { + -fx-font-family: 'Open Sans SemiBold'; + -fx-font-size: 1.0em; +} + .label-small { -fx-font-size: 0.8em; } @@ -1186,3 +1191,60 @@ -fx-background-color: MAIN_BG; -fx-font-size: 1.166667em; /* 14pt - 2 more than the default font */ } +/******************************************************************************* + * * + * Notification Window + * * + ******************************************************************************/ +.notification-window { + -fx-background-color: MAIN_BG; + -fx-background-radius: 8px; + -fx-border-radius: 8px; + -fx-background-insets: 0; + -fx-border-color: MUTED_BG; + -fx-border-width: 1px; + -fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.2), 28, 0.35, 0, 6); +} + +.notification-window .dialog-header { + -fx-alignment: center-left; +} + +.notification-window .close-button { + -fx-background-color: transparent; + -fx-border-color: transparent; + -fx-padding: 6 8 6 8; +} + +.notification-window .close-button:hover { + -fx-background-color: CONTROL_BG_HOVER; + -fx-background-radius: 8; +} + +.notification-window .close-button:pressed { + -fx-background-color: CONTROL_BG_ARMED; +} + +.notification-window .action-button:hover { + -fx-background-color: CONTROL_BG_HOVER; + -fx-border-color: CONTROL_BORDER_FOCUSED; +} + +.notification-window .action-button:pressed { + -fx-background-color: CONTROL_BG_ARMED; +} + +.notification-window .nav-button { + -fx-background-color: transparent; + -fx-border-color: transparent; + -fx-padding: 6 8 6 8; +} + +.notification-window .nav-button:hover { + -fx-background-color: CONTROL_BG_HOVER; + -fx-background-radius: 8; +} + +.notification-window .nav-button:pressed { + -fx-background-color: CONTROL_BG_ARMED; +} diff --git a/src/main/resources/css/light_theme.css b/src/main/resources/css/light_theme.css index 39e2892ac..2439000b6 100644 --- a/src/main/resources/css/light_theme.css +++ b/src/main/resources/css/light_theme.css @@ -113,6 +113,11 @@ -fx-font-size: 1.2em; } +.label-window-title { + -fx-font-family: 'Open Sans SemiBold'; + -fx-font-size: 1.0em; +} + .label-small { -fx-font-size: 0.8em; } @@ -1186,3 +1191,61 @@ -fx-background-color: MAIN_BG; -fx-font-size: 1.166667em; /* 14pt - 2 more than the default font */ } + +/******************************************************************************* + * * + * Notification Window + * * + ******************************************************************************/ +.notification-window { + -fx-background-color: MAIN_BG; + -fx-background-radius: 8px; + -fx-border-radius: 8px; + -fx-background-insets: 0; + -fx-border-color: MUTED_BG; + -fx-border-width: 1px; + -fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.2), 28, 0.35, 0, 6); +} + +.notification-window .dialog-header { + -fx-alignment: center-left; +} + +.notification-window .close-button { + -fx-background-color: transparent; + -fx-border-color: transparent; + -fx-padding: 6 8 6 8; +} + +.notification-window .close-button:hover { + -fx-background-color: CONTROL_BG_HOVER; + -fx-background-radius: 8; +} + +.notification-window .close-button:pressed { + -fx-background-color: CONTROL_BG_ARMED; +} + +.notification-window .action-button:hover { + -fx-background-color: CONTROL_BG_HOVER; + -fx-border-color: CONTROL_BORDER_FOCUSED; +} + +.notification-window .action-button:pressed { + -fx-background-color: CONTROL_BG_ARMED; +} + +.notification-window .nav-button { + -fx-background-color: transparent; + -fx-border-color: transparent; + -fx-padding: 6 8 6 8; +} + +.notification-window .nav-button:hover { + -fx-background-color: CONTROL_BG_HOVER; + -fx-background-radius: 8; +} + +.notification-window .nav-button:pressed { + -fx-background-color: CONTROL_BG_ARMED; +} \ No newline at end of file diff --git a/src/main/resources/fxml/notification.fxml b/src/main/resources/fxml/notification.fxml new file mode 100644 index 000000000..d209f7c64 --- /dev/null +++ b/src/main/resources/fxml/notification.fxml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ + + + +
+ diff --git a/src/main/resources/i18n/strings.properties b/src/main/resources/i18n/strings.properties index 1e136dbe3..ac631abe2 100644 --- a/src/main/resources/i18n/strings.properties +++ b/src/main/resources/i18n/strings.properties @@ -13,6 +13,7 @@ generic.button.close=Close generic.button.copy=Copy generic.button.copied=Copied! generic.button.done=Done +generic.button.previous=Previous generic.button.next=Next generic.button.print=Print generic.button.remove=Remove