use RMI to send images to Swing

Change-Id: I8e294492c6d19a3abf1576cfc7923ab2164eb1c9
diff --git a/projects/syncslidepresenter/src/main/java/io/v/syncslidepresenter/Main.java b/projects/syncslidepresenter/src/main/java/io/v/syncslidepresenter/Main.java
index 4fd4471..e706aaa 100644
--- a/projects/syncslidepresenter/src/main/java/io/v/syncslidepresenter/Main.java
+++ b/projects/syncslidepresenter/src/main/java/io/v/syncslidepresenter/Main.java
@@ -22,14 +22,27 @@
 import java.awt.Window;
 import java.awt.event.ComponentAdapter;
 import java.awt.event.ComponentEvent;
-import java.awt.image.BufferedImage;
+import java.io.BufferedReader;
 import java.io.ByteArrayInputStream;
 import java.io.File;
 import java.io.IOException;
+import java.io.InputStreamReader;
 import java.lang.reflect.Method;
 import java.util.HashSet;
 import java.util.Set;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.rmi.AlreadyBoundException;
+import java.rmi.Naming;
+import java.rmi.NotBoundException;
+import java.rmi.Remote;
+import java.rmi.RemoteException;
+import java.rmi.registry.LocateRegistry;
+import java.rmi.server.UnicastRemoteObject;
 import java.util.UUID;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
@@ -80,6 +93,15 @@
  *     ./gradlew :projects:syncslidepresenter:installDist
  *     ./projects/syncslidepresenter/build/install/syncslidepresenter/bin/syncslidepresenter
  * </pre>
+ *
+ * <p>For reasons not yet fully understood, applications running both Swing and Vanadium freeze
+ * randomly (at least on Mac OS X). We work around this by running two JVMs. The main JVM will
+ * create a sub-JVM in a separate thread, and will then proceed to join a syncgroup and look for
+ * slide data. Once a new slide is detected, the bytes representing the slide will be sent to
+ * the sub-JVM via RMI.
+ *
+ * <p>The sub-JVM simply listens for slide bytes and then, using {@link ImageIO#read}, decodes
+ * those bytes and displays the resulting image in a JFrame.
  */
 public class Main {
     private static final Logger logger = Logger.getLogger(Main.class.getName());
@@ -87,6 +109,7 @@
     private static final String SYNCBASE_DB = "syncslides";
     private static final String PRESENTATIONS_TABLE = "Presentations";
     private static final String DECKS_TABLE = "Decks";
+    private static final int MAX_PORT_PICKER_ATTEMPTS = 100;
     private final Table presentations;
     private final Table decks;
     private final ImageViewer viewer;
@@ -95,7 +118,9 @@
 
     private VContext context;
 
-    public static void main(String[] args) throws SyncbaseServer.StartException, VException, IOException {
+    public static void main(String[] args)
+            throws SyncbaseServer.StartException, VException, IOException, NotBoundException,
+            AlreadyBoundException {
         Options options = new Options();
         JCommander commander = new JCommander(options);
         try {
@@ -111,13 +136,16 @@
             return;
         }
 
-        // Make command-Q do the same as closing the main frame (i.e. exit).
-        System.setProperty("apple.eawt.quitStrategy", "CLOSE_ALL_WINDOWS");
+        ImageViewer viewer = null;
+        if (options.swing) {
+            // We're just going to be running Swing. Do that and then stop.
+            startSwingServer();
+            return;
+        }
 
-        JFrame frame = new JFrame();
-        enableOSXFullscreen(frame);
-        frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
-        frame.setVisible(true);
+        // Otherwise, we're a Vanadium client. Connect to the swing server and then
+        // set up vanadium.
+        viewer = startAndConnectToSwingServer(options.swingServerTimeoutSeconds);
 
         VContext baseContext = V.init();
 
@@ -156,17 +184,7 @@
                 presentations.create(baseContext, permissions);
             }
 
-            JPanel panel = new JPanel(new GridBagLayout());
-            ScaleToFitJPanel presentationPanel = new ScaleToFitJPanel();
-            GridBagConstraints constraints = new GridBagConstraints();
-            constraints.weightx = 1;
-            constraints.weighty = 1;
-            constraints.fill = GridBagConstraints.BOTH;
-            panel.add(presentationPanel, constraints);
-            frame.getContentPane().add(panel);
-            frame.pack();
-
-            Main m = new Main(baseContext, presentationPanel, db, decks, presentations);
+            Main m = new Main(baseContext, viewer, db, decks, presentations);
 
             Presentation presentation = new Discovery(
                     baseContext, options.mountPrefix, options.deckPrefix).getPresentation();
@@ -175,6 +193,36 @@
         }
     }
 
+    private static void startSwingServer() throws IOException, AlreadyBoundException {
+        logger.info("inside startSwingServer");
+        // Make command-Q do the same as closing the main frame (i.e. exit).
+        System.setProperty("apple.eawt.quitStrategy", "CLOSE_ALL_WINDOWS");
+
+        JFrame frame = new JFrame();
+        enableOSXFullscreen(frame);
+        frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
+        frame.setVisible(true);
+
+        JPanel panel = new JPanel(new GridBagLayout());
+        ScaleToFitJPanel presentationPanel = new ScaleToFitJPanel();
+        GridBagConstraints constraints = new GridBagConstraints();
+        constraints.weightx = 1;
+        constraints.weighty = 1;
+        constraints.fill = GridBagConstraints.BOTH;
+        panel.add(presentationPanel, constraints);
+        frame.getContentPane().add(panel);
+        frame.pack();
+
+        int port = pickUnusedPort();
+        LocateRegistry.createRegistry(port);
+        String name = "//localhost:" + port + "/" + UUID.randomUUID();
+        logger.info("swing server binding to " + name);
+        Naming.bind(name, new RemoteImageViewer(presentationPanel));
+
+        // The parent JVM will expect to read this value.
+        System.out.println(name);
+    }
+
     public Main(VContext context, ImageViewer viewer, Database db, Table decks,
                 Table presentations) throws VException {
         this.context = context;
@@ -216,9 +264,7 @@
                 String row = String.format(slideRowFormat, presentation.getDeckId(),
                         currentSlide.getNum());
                 VSlide slide = (VSlide) decks.getRow(row).get(context, VSlide.class);
-                final BufferedImage image = ImageIO.read(
-                        new ByteArrayInputStream(slide.getThumbnail()));
-                viewer.setImage(image);
+                viewer.setImage(slide.getThumbnail());
             } catch (IOException | VException e) {
                 logger.log(Level.WARNING, "exception encountered while handling change event", e);
             }
@@ -253,6 +299,9 @@
                 description = "mounttable prefix for live presentations.")
         private String deckPrefix = "happyDeck";
 
+        @Parameter(names = {"--swing"})
+        private boolean swing = false;
+
         @Parameter(names = {"-m", "--mountPrefix"}, description = "the base path in the namespace"
                 + " where the syncbase service will be mounted")
         private String mountPrefix = "/192.168.86.254:8101";
@@ -267,6 +316,10 @@
 
         @Parameter(names = {"-h", "--help"}, description = "display this help message", help = true)
         private boolean help = false;
+
+        @Parameter(names = {"--swingServerTimeoutSeconds"},
+                description = "the amount of time to wait for the swing server to start")
+        private int swingServerTimeoutSeconds = 10;
     }
 
     private static class ScaleToFitJPanel extends JPanel implements ImageViewer {
@@ -310,15 +363,111 @@
         }
 
         @Override
-        public void setImage(Image image) {
-            this.image = image;
-            setPreferredSize(new Dimension(image.getWidth(null), image.getHeight(null)));
-            repaint();
+        public void setImage(byte[] imageData) throws RemoteException {
+            try {
+                this.image = ImageIO.read(new ByteArrayInputStream(imageData));
+                setPreferredSize(new Dimension(image.getWidth(null), image.getHeight(null)));
+                repaint();
+            } catch (IOException e) {
+                throw new RemoteException("Could not set image", e);
+            }
         }
     }
 
-    private interface ImageViewer {
-        void setImage(Image image);
+    private interface ImageViewer extends Remote {
+        void setImage(byte[] imageData) throws RemoteException;
+    }
+
+    private static class RemoteImageViewer extends UnicastRemoteObject implements ImageViewer {
+        private final ImageViewer viewer;
+
+        protected RemoteImageViewer(ImageViewer viewer) throws RemoteException {
+            super();
+
+            this.viewer = viewer;
+        }
+
+        @Override
+        public void setImage(byte[] imageData) throws RemoteException {
+            viewer.setImage(imageData);
+        }
+    }
+
+    private static ImageViewer startAndConnectToSwingServer(int timeoutSeconds) throws IOException,
+            NotBoundException {
+        String javaHome = System.getProperty("java.home");
+        if (javaHome == null) {
+            throw new IllegalStateException("System property java.home is not set");
+        }
+        File javaBin = new File(javaHome, Joiner.on(File.separator).join("bin", "java"));
+        if (!javaBin.exists() || !javaBin.canExecute()) {
+            throw new IllegalStateException("Java binary " + javaBin
+                    + " does not exist or is not executable");
+        }
+        ProcessBuilder builder = new ProcessBuilder(javaBin.getAbsolutePath(),
+                "-classpath", System.getProperty("java.class.path"),
+                Main.class.getCanonicalName(), "--swing");
+        builder.redirectError(ProcessBuilder.Redirect.INHERIT);
+        logger.info("starting " + builder.command());
+        // The first line of output should be the address to which we should connect.
+        final Process process = builder.start();
+        logger.info("started " + process);
+
+        // Get a timeout timer going.
+        ScheduledExecutorService service = Executors.newSingleThreadScheduledExecutor();
+        service.schedule(new Runnable() {
+            @Override
+            public void run() {
+                process.destroyForcibly();
+            }
+        }, timeoutSeconds, TimeUnit.SECONDS);
+        BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
+        String name = reader.readLine();
+        // If the timeout didn't fire, we don't want it killing our process now!
+        service.shutdownNow();
+        if (name != null) {
+            logger.info("vanadium listener sending slide images to " + name);
+            ImageViewer viewer = (ImageViewer) Naming.lookup(name);
+            // Hack: when the swing viewer exits, terminate our main app.
+            new Thread(new Runnable() {
+                @Override
+                public void run() {
+                    do {
+                        try {
+                            process.waitFor();
+                            break;
+                        } catch (InterruptedException e) {
+                            // Ignored.
+                        }
+                    } while (true);
+                    System.exit(0);
+                }
+            }, "SwingServerTerminationWatcher").start();
+            return viewer;
+        } else {
+            throw new IOException("could not read slide name");
+        }
+    }
+
+    private static int pickUnusedPort() throws IOException {
+        for (int i = 0; i < MAX_PORT_PICKER_ATTEMPTS; i++) {
+            ServerSocket socket = new ServerSocket();
+            try {
+                socket.bind(new InetSocketAddress(0));
+                int port = ((InetSocketAddress) socket.getLocalSocketAddress()).getPort();
+                return port;
+            } catch (IOException e) {
+                // Couldn't bind, try again.
+            } finally {
+                try {
+                    socket.close();
+                } catch (IOException closeException) {
+                    logger.log(Level.WARNING, "Exception caught during close", closeException);
+                }
+            }
+        }
+
+        throw new IOException("Couldn't locate unused port");
     }
 
     private static class Discovery {