/* * Syncany, www.syncany.org * Copyright (C) 2011-2013 Philipp C. Heckel <philipp.heckel@gmail.com> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.syncany.gui.tray; import static io.undertow.Handlers.path; import static io.undertow.Handlers.resource; import static io.undertow.Handlers.websocket; import io.undertow.Undertow; import io.undertow.server.HttpHandler; import io.undertow.server.handlers.resource.ClassPathResourceManager; import io.undertow.websockets.WebSocketConnectionCallback; import io.undertow.websockets.core.AbstractReceiveListener; import io.undertow.websockets.core.BufferedTextMessage; import io.undertow.websockets.core.StreamSourceFrameChannel; import io.undertow.websockets.core.WebSocketChannel; import io.undertow.websockets.core.WebSockets; import io.undertow.websockets.spi.WebSocketHttpExchange; import java.io.BufferedReader; import java.io.File; import java.io.IOException; import java.io.InputStreamReader; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import java.util.logging.Level; import java.util.logging.Logger; import org.eclipse.swt.widgets.Shell; import org.syncany.operations.daemon.messages.ClickRecentChangesGuiInternalEvent; import org.syncany.operations.daemon.messages.ClickTrayMenuFolderGuiInternalEvent; import org.syncany.operations.daemon.messages.ClickTrayMenuGuiInternalEvent; import org.syncany.operations.daemon.messages.DisplayNotificationGuiInternalEvent; import org.syncany.operations.daemon.messages.ListWatchesManagementRequest; import org.syncany.operations.daemon.messages.UpdateRecentChangesGuiInternalEvent; import org.syncany.operations.daemon.messages.UpdateStatusTextGuiInternalEvent; import org.syncany.operations.daemon.messages.UpdateTrayIconGuiInternalEvent; import org.syncany.operations.daemon.messages.UpdateWatchesGuiInternalEvent; import org.syncany.operations.daemon.messages.api.Message; import org.syncany.operations.daemon.messages.api.XmlMessageFactory; import org.syncany.util.StringUtil; /** * The app indicator tray icon uses a Python script to create * a so called "app indicator" (introduced by Ubuntu Unity). * * <p>The class starts a Python script that creates an app indicator * and connects to the embedded web and websocket server. The embedded * server serves static content (tray icon images) and provides a * websocket server to communicate between the script and this class. * * @see https://unity.ubuntu.com/projects/appindicators/ * @author Philipp C. Heckel <philipp.heckel@gmail.com> * @author Vincent Wiencek <vwiencek@gmail.com> */ public class AppIndicatorTrayIcon extends TrayIcon { private static final Logger logger = Logger.getLogger(AppIndicatorTrayIcon.class.getSimpleName()); private static String WEBSERVER_HOST = "127.0.0.1"; private static int WEBSERVER_PORT = 51601; private static String WEBSERVER_PATH_HTTP_RESOURCES = "/api/res"; private static String WEBSERVER_PATH_WEBSOCKET_XML = "/api/ws/xml"; private static String WEBSERVER_ENDPOINT_HTTP = "http://" + WEBSERVER_HOST + ":" + WEBSERVER_PORT + WEBSERVER_PATH_HTTP_RESOURCES; private static String WEBSERVER_ENDPOINT_HTTP_THEME_FORMAT = WEBSERVER_ENDPOINT_HTTP + "/%s"; private static String WEBSERVER_ENDPOINT_WEBSOCKET = "ws://" + WEBSERVER_HOST + ":" + WEBSERVER_PORT + WEBSERVER_PATH_WEBSOCKET_XML; private static String WEBSERVER_URL_SCRIPT = WEBSERVER_ENDPOINT_HTTP + "/tray.py"; private static String PYTHON_LAUNCH_SCRIPT = "import urllib2; base_url = '%s'; ws_url = '%s'; exec urllib2.urlopen('%s').read()"; private Undertow webServer; private Process pythonProcess; private AtomicBoolean pythonProcessRestart; private WebSocketChannel pythonClientChannel; public AppIndicatorTrayIcon(Shell shell, TrayIconTheme theme) { super(shell, theme); startWebServer(); startTray(); } private void startWebServer() { String resourcesRoot = TrayIcon.class.getPackage().getName().replace(".", "/"); HttpHandler pathHttpHandler = path() .addPrefixPath(WEBSERVER_PATH_WEBSOCKET_XML, websocket(new InternalWebSocketHandler())) .addPrefixPath(WEBSERVER_PATH_HTTP_RESOURCES, resource(new ClassPathResourceManager(TrayIcon.class.getClassLoader(), resourcesRoot))); webServer = Undertow .builder() .addHttpListener(WEBSERVER_PORT, WEBSERVER_HOST) .setHandler(pathHttpHandler) .build(); webServer.start(); } private void startTray() { new Thread(new Runnable() { @Override public void run() { startPythonProcessWithRestartLoop(); } }, "PyAppInd").start(); } private void startPythonProcessWithRestartLoop() { pythonProcessRestart = new AtomicBoolean(true); int restartCount = 0; while (pythonProcessRestart.get()) { try { startPythonProcess(); startPythonLoggerThreads(); if (restartCount > 0) { waitAndSendListWatchesRequest(); } waitForPythonProcess(); // Wait indefinitely (or until it crashes!) waitBeforeRestart(); restartCount++; } catch (IOException e) { logger.log(Level.SEVERE, "ERROR starting AppIndicator Python process.", e); } catch (InterruptedException e) { logger.log(Level.SEVERE, "Python process or sleep interrupted. NOT RESTARTING. Assuming we want this process to die.", e); pythonProcessRestart.set(false); } } } private void startPythonProcess() throws IOException { String webServerEndpointHttpWithTheme = String.format(WEBSERVER_ENDPOINT_HTTP_THEME_FORMAT, getTheme().toString().toLowerCase()); String startScript = String.format(PYTHON_LAUNCH_SCRIPT, new Object[] { webServerEndpointHttpWithTheme, WEBSERVER_ENDPOINT_WEBSOCKET, WEBSERVER_URL_SCRIPT }); String[] command = new String[] { "/usr/bin/python", "-c", startScript }; ProcessBuilder processBuilder = new ProcessBuilder(command); logger.log(Level.INFO, "Starting external app indicator command: " + StringUtil.join(command, " ")); pythonProcess = processBuilder.start(); } private void startPythonLoggerThreads() { BufferedReader scriptStdOutReader = new BufferedReader(new InputStreamReader(pythonProcess.getInputStream())); BufferedReader scriptStdErrReader = new BufferedReader(new InputStreamReader(pythonProcess.getErrorStream())); launchLoggerThread(scriptStdOutReader, "Python Input Stream: ", "PySTDIN"); launchLoggerThread(scriptStdErrReader, "Python Error Stream: ", "PySTDERR"); } private void waitAndSendListWatchesRequest() throws InterruptedException { logger.log(Level.INFO, "Python process started after crash (!): Waiting a bit, then sending list watches request ..."); Thread.sleep(5000); eventBus.post(new ListWatchesManagementRequest()); } private void waitForPythonProcess() throws InterruptedException { logger.log(Level.INFO, "Python process started. Waiting indefinitely (or until it crashes :-/) ..."); pythonProcess.waitFor(); } private void waitBeforeRestart() throws InterruptedException { if (pythonProcessRestart.get()) { logger.log(Level.INFO, "Python process crashed. Waiting a bit before restart."); Thread.sleep(3000); } else { logger.log(Level.INFO, "Python process crashed or terminated. NO RESTART requested."); } } @Override protected void exitApplication() { pythonProcessRestart.set(false); pythonProcess.destroy(); webServer.stop(); super.exitApplication(); } private void launchLoggerThread(final BufferedReader stdinReader, final String prefix, String threadName) { logger.log(Level.INFO, "Starting Python logger thread for '" + threadName + "' ..."); Thread loggerThread = new Thread(new Runnable() { @Override public void run() { try { String line; while ((line = stdinReader.readLine()) != null) { logger.log(Level.INFO, prefix + line); } } catch (Exception e) { logger.log(Level.SEVERE, "Unable to read from Python STDIN/STDERR.", e); } } }, threadName); loggerThread.start(); } @Override public void setWatchedFolders(List<File> folders) { sendWebSocketMessage(new UpdateWatchesGuiInternalEvent(new ArrayList<>(folders))); } @Override public void setStatusText(String root, String statusText) { sendWebSocketMessage(new UpdateStatusTextGuiInternalEvent(root, statusText)); } @Override protected void setTrayImage(TrayIconImage image) { sendWebSocketMessage(new UpdateTrayIconGuiInternalEvent(image.getFileName())); } @Override protected void setRecentChanges(List<File> recentFiles) { sendWebSocketMessage(new UpdateRecentChangesGuiInternalEvent(new ArrayList<>(recentFiles))); } @Override protected void displayNotification(String subject, String message) { sendWebSocketMessage(new DisplayNotificationGuiInternalEvent(subject, message)); } public void sendWebSocketMessage(Message message) { if (pythonClientChannel != null) { try { String messageStr = XmlMessageFactory.toXml(message); logger.log(Level.INFO, "Sending message: " + messageStr); WebSockets.sendText(messageStr, pythonClientChannel, null); } catch (Exception e) { logger.log(Level.WARNING, "Cannot send message. Failed to create/send message.", e); } } } private void handleWebSocketMessage(WebSocketChannel clientSocket, String messageStr) { logger.log(Level.INFO, "Web socket message received: " + messageStr); try { Message message = XmlMessageFactory.toMessage(messageStr); if (message instanceof ClickTrayMenuFolderGuiInternalEvent) { ClickTrayMenuFolderGuiInternalEvent folderClickEvent = (ClickTrayMenuFolderGuiInternalEvent) message; File folder = new File(folderClickEvent.getFolder()); switch (folderClickEvent.getAction()) { case OPEN: showFolder(folder); break; case COPY_LINK: copyLink(folder); break; case REMOVE: removeFolder(folder); break; } } else if (message instanceof ClickRecentChangesGuiInternalEvent) { ClickRecentChangesGuiInternalEvent folderClickEvent = (ClickRecentChangesGuiInternalEvent) message; File file = new File(folderClickEvent.getFile()); showRecentFile(file); } else if (message instanceof ClickTrayMenuGuiInternalEvent) { ClickTrayMenuGuiInternalEvent clickEvent = (ClickTrayMenuGuiInternalEvent) message; switch (clickEvent.getAction()) { case NEW: showNew(); break; case BROWSE_HISTORY: showBrowseHistory(); break; case PREFERENCES: showPreferences(); break; case REPORT_ISSUE: showReportIssue(); break; case DONATE: showDonate(); break; case WEBSITE: showWebsite(); break; case EXIT: exitApplication(); break; } } else { logger.log(Level.WARNING, "UNKNOWN MESSAGE. IGNORING."); } } catch (Exception e) { logger.log(Level.WARNING, "Invalid request received; cannot serialize to Request.", e); } } private class InternalWebSocketHandler implements WebSocketConnectionCallback { @Override public void onConnect(WebSocketHttpExchange exchange, WebSocketChannel channel) { // Validate origin header (security!) String originHeader = exchange.getRequestHeader("Origin"); if (originHeader != null && !originHeader.startsWith("http://" + WEBSERVER_HOST + ":" + WEBSERVER_PORT)) { logger.log(Level.INFO, channel.toString() + " disconnected due to invalid origin header: " + originHeader); exchange.close(); } logger.log(Level.INFO, "Valid origin header, setting up connection."); channel.getReceiveSetter().set(new AbstractReceiveListener() { @Override protected void onFullTextMessage(WebSocketChannel clientChannel, BufferedTextMessage message) { handleWebSocketMessage(clientChannel, message.getData()); } @Override protected void onError(WebSocketChannel webSocketChannel, Throwable error) { logger.log(Level.INFO, "Server error : " + error.toString()); } @Override protected void onClose(WebSocketChannel clientChannel, StreamSourceFrameChannel streamSourceChannel) throws IOException { logger.log(Level.INFO, clientChannel.toString() + " disconnected"); } }); pythonClientChannel = channel; channel.resumeReceives(); } } @Override protected void dispose() { // Do nothing. } }