diff --git a/main/ui/src/main/java/org/cryptomator/ui/MainApplication.java b/main/ui/src/main/java/org/cryptomator/ui/MainApplication.java index 21db728b3..aad82c792 100644 --- a/main/ui/src/main/java/org/cryptomator/ui/MainApplication.java +++ b/main/ui/src/main/java/org/cryptomator/ui/MainApplication.java @@ -13,12 +13,14 @@ import java.util.ResourceBundle; import java.util.Set; import javafx.application.Application; +import javafx.application.Platform; import javafx.fxml.FXMLLoader; import javafx.scene.Parent; import javafx.scene.Scene; import javafx.stage.Stage; import org.cryptomator.ui.settings.Settings; +import org.cryptomator.ui.util.TrayIconUtil; import org.eclipse.jetty.util.ConcurrentHashSet; public class MainApplication extends Application { @@ -27,30 +29,41 @@ public class MainApplication extends Application { private static final CleanShutdownPerformer CLEAN_SHUTDOWN_PERFORMER = new CleanShutdownPerformer(); public static void main(String[] args) { - launch(args); + Application.launch(args); Runtime.getRuntime().addShutdownHook(CLEAN_SHUTDOWN_PERFORMER); } @Override public void start(final Stage primaryStage) throws IOException { - final ResourceBundle localizations = ResourceBundle.getBundle("localization"); - final FXMLLoader loader = new FXMLLoader(getClass().getResource("/main.fxml"), localizations); + final ResourceBundle rb = ResourceBundle.getBundle("localization"); + final FXMLLoader loader = new FXMLLoader(getClass().getResource("/main.fxml"), rb); final Parent root = loader.load(); final MainController ctrl = loader.getController(); ctrl.setStage(primaryStage); final Scene scene = new Scene(root); - primaryStage.setTitle("Cryptomator"); + primaryStage.setTitle(rb.getString("app.name")); primaryStage.setScene(scene); primaryStage.sizeToScene(); primaryStage.setResizable(false); primaryStage.show(); + TrayIconUtil.init(primaryStage, rb, () -> { + quit(); + }); + } + + private void quit() { + Platform.runLater(() -> { + CLEAN_SHUTDOWN_PERFORMER.run(); + Settings.save(); + Platform.exit(); + System.exit(0); + }); } @Override - public void stop() throws Exception { + public void stop() { CLEAN_SHUTDOWN_PERFORMER.run(); Settings.save(); - super.stop(); } public static void addShutdownTask(Runnable r) { diff --git a/main/ui/src/main/java/org/cryptomator/ui/MainController.java b/main/ui/src/main/java/org/cryptomator/ui/MainController.java index 1f6310830..ebc244700 100644 --- a/main/ui/src/main/java/org/cryptomator/ui/MainController.java +++ b/main/ui/src/main/java/org/cryptomator/ui/MainController.java @@ -11,8 +11,11 @@ package org.cryptomator.ui; import java.io.File; import java.io.IOException; import java.net.URL; +import java.util.Collection; import java.util.ResourceBundle; +import java.util.stream.Collectors; +import javafx.application.Platform; import javafx.collections.ListChangeListener; import javafx.event.ActionEvent; import javafx.fxml.FXML; @@ -82,7 +85,7 @@ public class MainController implements Initializable, InitializationListener, Un stage.setTitle(selectedDir.getName()); showDirectory(selectedDir); } - + private void showDirectory(Directory directory) { try { if (directory.isUnlocked()) { @@ -133,6 +136,7 @@ public class MainController implements Initializable, InitializationListener, Un @Override public void didUnlock(UnlockController ctrl) { showUnlockedView(ctrl.getDirectory()); + Platform.setImplicitExit(false); } private void showUnlockedView(Directory directory) { @@ -144,6 +148,19 @@ public class MainController implements Initializable, InitializationListener, Un @Override public void didLock(UnlockedController ctrl) { showUnlockView(ctrl.getDirectory()); + if (getUnlockedDirectories().isEmpty()) { + Platform.setImplicitExit(true); + } + } + + /* Convenience */ + + public Collection getDirectories() { + return directoryList.getItems(); + } + + public Collection getUnlockedDirectories() { + return getDirectories().stream().filter(d -> d.isUnlocked()).collect(Collectors.toSet()); } /* public Getter/Setter */ diff --git a/main/ui/src/main/java/org/cryptomator/ui/model/Directory.java b/main/ui/src/main/java/org/cryptomator/ui/model/Directory.java index 0ace7a4d9..de7056ddd 100644 --- a/main/ui/src/main/java/org/cryptomator/ui/model/Directory.java +++ b/main/ui/src/main/java/org/cryptomator/ui/model/Directory.java @@ -113,6 +113,23 @@ public class Directory implements Serializable { return server; } + /* hashcode/equals */ + + @Override + public int hashCode() { + return path.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof Directory) { + final Directory other = (Directory) obj; + return this.path.equals(other.path); + } else { + return false; + } + } + /* graceful shutdown */ private class ShutdownTask implements Runnable { diff --git a/main/ui/src/main/java/org/cryptomator/ui/util/TrayIconUtil.java b/main/ui/src/main/java/org/cryptomator/ui/util/TrayIconUtil.java new file mode 100644 index 000000000..9cf25d28b --- /dev/null +++ b/main/ui/src/main/java/org/cryptomator/ui/util/TrayIconUtil.java @@ -0,0 +1,119 @@ +package org.cryptomator.ui.util; + +import java.awt.AWTException; +import java.awt.Image; +import java.awt.MenuItem; +import java.awt.PopupMenu; +import java.awt.SystemTray; +import java.awt.Toolkit; +import java.awt.TrayIcon; +import java.awt.TrayIcon.MessageType; +import java.awt.event.ActionEvent; +import java.io.IOException; +import java.util.ResourceBundle; + +import javafx.application.Platform; +import javafx.stage.Stage; + +import javax.swing.SwingUtilities; + +import org.apache.commons.lang3.SystemUtils; + +public final class TrayIconUtil { + + private static TrayIconUtil INSTANCE; + + private final Stage mainApplicationWindow; + private final ResourceBundle rb; + private final Runnable exitCommand; + + /** + * This will add an icon to the system tray and modify the application shutdown procedure. Depending on + * {@link Platform#isImplicitExit()} the application may still be running, allowing shutdown using the tray menu. + */ + public synchronized static void init(Stage mainApplicationWindow, ResourceBundle rb, Runnable exitCommand) { + if (INSTANCE == null && SystemTray.isSupported()) { + INSTANCE = new TrayIconUtil(mainApplicationWindow, rb, exitCommand); + } + } + + private TrayIconUtil(Stage mainApplicationWindow, ResourceBundle rb, Runnable exitCommand) { + this.mainApplicationWindow = mainApplicationWindow; + this.rb = rb; + this.exitCommand = exitCommand; + + initTrayIcon(); + } + + private void initTrayIcon() { + final TrayIcon trayIcon = createTrayIcon(); + try { + SystemTray.getSystemTray().add(trayIcon); + mainApplicationWindow.setOnCloseRequest((e) -> { + if (Platform.isImplicitExit()) { + exitCommand.run(); + } else { + mainApplicationWindow.close(); + this.showTrayNotification(trayIcon); + } + }); + } catch (SecurityException | AWTException ex) { + // not working? then just go ahead and close the app + mainApplicationWindow.setOnCloseRequest((ev) -> { + exitCommand.run(); + }); + } + } + + private TrayIcon createTrayIcon() { + final PopupMenu popup = new PopupMenu(); + + final MenuItem showItem = new MenuItem(rb.getString("tray.menu.open")); + showItem.addActionListener(this::restoreFromTray); + popup.add(showItem); + + final MenuItem exitItem = new MenuItem(rb.getString("tray.menu.quit")); + exitItem.addActionListener(this::quitFromTray); + popup.add(exitItem); + + final Image image = Toolkit.getDefaultToolkit().getImage(TrayIconUtil.class.getResource("/tray_icon.png")); + return new TrayIcon(image, rb.getString("app.name"), popup); + } + + private void showTrayNotification(TrayIcon trayIcon) { + final Runnable notificationCmd; + if (SystemUtils.IS_OS_MAC_OSX) { + final String title = rb.getString("tray.infoMsg.title"); + final String msg = rb.getString("tray.infoMsg.msg.osx"); + final String notificationCenterAppleScript = String.format("display notification \"%s\" with title \"%s\"", msg, title); + notificationCmd = () -> { + try { + Runtime.getRuntime().exec(new String[] {"/usr/bin/osascript", "-e", notificationCenterAppleScript}); + } catch (IOException e) { + // ignore, user will notice the tray icon anyway. + } + }; + } else { + final String title = rb.getString("tray.infoMsg.title"); + final String msg = rb.getString("tray.infoMsg.msg"); + notificationCmd = () -> { + trayIcon.displayMessage(title, msg, MessageType.INFO); + }; + } + SwingUtilities.invokeLater(() -> { + notificationCmd.run(); + }); + } + + private void restoreFromTray(ActionEvent event) { + Platform.runLater(() -> { + mainApplicationWindow.show(); + mainApplicationWindow.requestFocus(); + }); + } + + private void quitFromTray(ActionEvent event) { + exitCommand.run(); + } + +} diff --git a/main/ui/src/main/resources/localization.properties b/main/ui/src/main/resources/localization.properties index 165304d4f..e631f86e8 100644 --- a/main/ui/src/main/resources/localization.properties +++ b/main/ui/src/main/resources/localization.properties @@ -7,6 +7,8 @@ # Sebastian Stenzel - initial API and implementation #------------------------------------------------------------------------------- +app.name=Cryptomator + # welcome.fxml welcome.welcomeLabel=Welcome to Cryptomator @@ -34,4 +36,12 @@ unlock.messageLabel.startServerFailed=Starting WebDAV server failed. # unlocked.fxml unlocked.messageLabel.runningOnPort=Vault is accessible via WebDAV on local port %d. -unlocked.button.lock=Lock vault \ No newline at end of file +unlocked.button.lock=Lock vault + + +# tray icon +tray.menu.open=Open +tray.menu.quit=Quit +tray.infoMsg.title=Still running +tray.infoMsg.msg=Cryptomator is still alive. Quit it from the tray icon. +tray.infoMsg.msg.osx=Cryptomator is still alive. Quit it from the menu bar icon. diff --git a/main/ui/src/main/resources/tray_icon.png b/main/ui/src/main/resources/tray_icon.png new file mode 100644 index 000000000..0f3bf126c Binary files /dev/null and b/main/ui/src/main/resources/tray_icon.png differ