/* * Copyright 2011 Christian Thiemann <christian@spato.net> * Developed at Northwestern University <http://rocs.northwestern.edu> * * This file is part of the SPaTo Visual Explorer (SPaTo). * * SPaTo 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. * * SPaTo 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 SPaTo. If not, see <http://www.gnu.org/licenses/>. */ package net.spato.sve.app; import java.awt.BorderLayout; import java.awt.Dimension; import java.io.BufferedReader; import java.io.File; import java.io.InputStream; import java.io.InputStreamReader; import java.net.URL; import java.security.KeyFactory; import java.security.Signature; import java.security.spec.X509EncodedKeySpec; import javax.swing.JCheckBox; import javax.swing.JLabel; import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JScrollPane; import net.spato.sve.app.util.*; import org.xhtmlrenderer.simple.FSScrollPane; import org.xhtmlrenderer.simple.XHTMLPanel; import processing.core.PApplet; import processing.xml.StdXMLBuilder; import processing.xml.StdXMLParser; import processing.xml.StdXMLReader; import processing.xml.XMLElement; import processing.xml.XMLException; import processing.xml.XMLValidator; public class Updater extends Thread { protected SPaTo_Visual_Explorer app = null; boolean force = false; String updateURL = System.getProperty("spato.update.url", "http://update.spato.net/latest/"); String releaseNotesURL = System.getProperty("spato.release-notes.url", "http://update.spato.net/release-notes/"); String indexName = null; String appRootFolder = null; String cacheFolder = null; XMLElement index = null; String updateVersion = null; // public key for file verification (base64-encoded) String pubKey64 = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrWkHeVPecXQeOd2" + "C3K4UUzgBqXYJwfGNKZnLp17wy/45nH7/llxBKR7eioJPdYCauxQ8M" + "nuArSltlIV9AnBKxb8h28xoBsEx1ek04jvJEtd93Bw7ILa3eF4MDGl" + "ZxwPnmTaTICIVUXtiZveOHDl1dQBKvinyU8fe3Xi7+j9klnwIDAQAB"; public Updater(SPaTo_Visual_Explorer app, boolean force) { setPriority(Thread.MIN_PRIORITY); this.app = app; this.force = force; } public void printOut(String msg) { System.out.println("+++ SPaTo Updater: " + msg); } public void printErr(String msg) { System.err.println("!!! SPaTo Updater: " + msg); } public void setupEnvironment() { printOut("updateURL = " + updateURL); // determine which INDEX file to download switch (PApplet.platform) { case PApplet.LINUX: indexName = "INDEX.linux"; break; case PApplet.MACOSX: indexName = "INDEX.macosx"; break; case PApplet.WINDOWS: indexName = "INDEX.windows"; break; default: throw new RuntimeException("unsupported platform"); } printOut("indexName = " + indexName); // check application root folder appRootFolder = System.getProperty("spato.app-dir"); if ((appRootFolder == null) || !new File(appRootFolder).exists()) throw new RuntimeException("invalid application root folder: " + appRootFolder); if (!appRootFolder.endsWith(File.separator)) appRootFolder += File.separator; printOut("appRootFolder = " + appRootFolder); // check update cache folder switch (PApplet.platform) { case PApplet.LINUX: cacheFolder = System.getProperty("user.home") + "/.spato/update"; break; case PApplet.MACOSX: cacheFolder = appRootFolder + "Contents/Resources/update"; break; default: cacheFolder = appRootFolder + "update"; break; } if ((cacheFolder == null) || !new File(cacheFolder).exists() && !new File(cacheFolder).mkdirs()) throw new RuntimeException("could not create cache folder: " + cacheFolder); if (!cacheFolder.endsWith(File.separator)) cacheFolder += File.separator; printOut("cacheFolder = " + cacheFolder); } public boolean checkAndFetch() { // returns true if update is available int count = 0, totalSize = 0; BufferedReader reader = null; // fetch the index try { // reader creation copied from PApplet so we can catch exceptions InputStream is = new URL(updateURL + indexName).openStream(); reader = new BufferedReader(new InputStreamReader(is, "UTF-8")); // XML parsing copied from XMLElement so we can catch exceptions index = new XMLElement(); StdXMLParser parser = new StdXMLParser(); parser.setBuilder(new StdXMLBuilder(index)); parser.setValidator(new XMLValidator()); parser.setReader(new StdXMLReader(reader)); parser.parse(); } catch (XMLException xmle) { index = null; throw new RuntimeException("Not a valid XML file: " + updateURL + indexName + "<br>" + "Are you properly connected to the interwebs?"); } catch (Exception e) { // FIXME: react to specific exceptions index = null; throw new RuntimeException("could not download " + indexName, e); } finally { try { reader.close(); } catch (Exception e) { } } // check whether the user wants to ignore this update try { updateVersion = index.getChild("release").getString("version"); } catch (Exception e) {} printOut("INDEX is for version " + updateVersion); if ((updateVersion != null) && updateVersion.equals(app.prefs.get("update.skip", null))) { printOut("user requested to skip this version"); return false; } else app.prefs.remove("update.skip"); // delete possibly existing locally cached index new File(cacheFolder + "INDEX").delete(); // setup signature verification Signature sig = null; try { sig = Signature.getInstance("MD5withRSA"); X509EncodedKeySpec spec = new X509EncodedKeySpec(Base64.decode(pubKey64)); sig.initVerify(KeyFactory.getInstance("RSA").generatePublic(spec)); } catch (Exception e) { throw new RuntimeException("failed to setup signature verification", e); } // iterate over all file records for (XMLElement file : index.getChildren("file")) { XMLElement remote = file.getChild("remote"), local = file.getChild("local"); // check if all information is present if ((remote == null) || (remote.getString("path") == null) || (remote.getString("md5") == null) || (local == null) || (local.getString("path") == null)) throw new RuntimeException("malformed file record: " + file); // check for signature and decode byte signature[] = null; if ((file.getChild("signature") == null) || (file.getChild("signature").getContent() == null)) throw new RuntimeException("missing file signature: " + file); try { signature = Base64.decode(file.getChild("signature").getContent()); } catch (Exception e) { throw new RuntimeException("error decoding signature: " + file, e); } // download update file if necessary local.setString("md5", "" + MD5.digest(appRootFolder + local.getString("path"))); // "" forces "null" if md5 returns null if (!remote.getString("md5").equals(local.getString("md5"))) { count++; // count number of outdated files String cacheFilename = cacheFolder + remote.getString("path").replace('/', File.separatorChar); if (remote.getString("md5").equals(MD5.digest(cacheFilename))) printOut(remote.getString("path") + " is outdated, but update is already cached"); else { printOut(remote.getString("path") + " is outdated, downloading update (" + remote.getInt("size") + " bytes)"); byte buf[] = new byte[remote.getInt("size", 0)]; int len = 0; InputStream is = null; try { is = new URL(updateURL + remote.getString("path")).openStream(); while (len < buf.length) len += is.read(buf, len, buf.length - len); } catch (Exception e) { printErr("download failed"); e.printStackTrace(); return false; } finally { try { is.close(); } catch (Exception e) {} } try { sig.update(buf); if (!sig.verify(signature)) throw new Exception("signature verification failure"); } catch (Exception e) { printErr("failed to verify file"); e.printStackTrace(); return false; } app.saveBytes(cacheFilename, buf); totalSize += remote.getInt("size"); // keep track of total download volume if (!remote.getString("md5").equals(MD5.digest(cacheFilename))) throw new RuntimeException("md5 mismatch: " + file); } } } // clean up and return if (count > 0) { printOut("updates available for " + count + " files, downloaded " + totalSize + " bytes"); return true; } else { printOut("no updates available"); new File(cacheFolder).delete(); return false; } } public String[] getRestartCmd() { switch (PApplet.platform) { case PApplet.LINUX: return new String[] { System.getProperty("spato.exec"), "--restart" }; case PApplet.MACOSX: return new String[] { appRootFolder + "Contents/Resources/restart.sh" }; case PApplet.WINDOWS: return new String[] { appRootFolder + "lib\\restart.bat", appRootFolder, ">", appRootFolder + "lib\\restart_to_update.log", "2>&1" }; default: return null; } } final static int NOTHING = -1, IGNORE = 0, INSTALL = 1, RESTART = 2; public int showReleaseNotesDialog(boolean canRestart) { // construct URL request String url = releaseNotesURL + "?version=" + app.VERSION + "&index=" + indexName; // setup HTML renderer for release notes XHTMLPanel htmlView = new XHTMLPanel(); try { htmlView.setDocument(url); } catch (Exception e) { throw new RuntimeException("could not fetch release notes from " + url, e); } JScrollPane scrollPane = new FSScrollPane(htmlView); scrollPane.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_ALWAYS); scrollPane.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED); // compose everything in a panel JPanel panel = new JPanel(new BorderLayout(0, 10)); panel.add(new JLabel("An update is available and can be applied the next time you start SPaTo Visual Explorer."), BorderLayout.NORTH); panel.add(scrollPane, BorderLayout.CENTER); panel.add(new JLabel("<html>You are currently running version <b>" + app.VERSION + "</b> (" + app.VERSION_DATE + ").</html>"), BorderLayout.SOUTH); panel.setPreferredSize(new Dimension(600, 400)); panel.setMinimumSize(new Dimension(300, 200)); // add the auto-check checkbox JCheckBox cbAutoUpdate = new JCheckBox("Automatically check for updates in the future", app.prefs.getBoolean("update.check", true)); JPanel panel2 = new JPanel(new BorderLayout(0, 20)); panel2.add(panel, BorderLayout.CENTER); panel2.add(cbAutoUpdate, BorderLayout.SOUTH); // setup the options Object options[] = canRestart ? new Object[] { "Restart now", "Restart later", "Skip this update" } : new Object[] { "Awesome!", "Skip this update" }; // show the dialog int result = JOptionPane.showOptionDialog(app.frame, panel2, "Good news, everyone!", JOptionPane.INFORMATION_MESSAGE, JOptionPane.YES_NO_CANCEL_OPTION, null, options, options[0]); // save the auto-check selection app.prefs.putBoolean("update.check", cbAutoUpdate.isSelected()); // return the proper action constant if (result == (canRestart ? 2 : 1)) return IGNORE; // skip this update if (result == (canRestart ? 1 : 0)) return INSTALL; // install on next application launch if (result == 0 && canRestart) return RESTART; // install now return NOTHING; // this will cause to do nothing (no kidding!) } public void askAndAct() { while (app.fireworks) try { Thread.sleep(5000); } catch (Exception e) {} String cmd[] = getRestartCmd(); int action = showReleaseNotesDialog(cmd != null); // check if the user wants to ignore this update if (action == IGNORE) app.prefs.put("update.skip", updateVersion); // save the INDEX into the update cache folder to indicate that the update should be installed if ((action == INSTALL) || (action == RESTART)) index.write(app.createWriter(cacheFolder + "INDEX")); // restart application if requested if (action == RESTART) try { new ProcessBuilder(cmd).start(); app.exit(); // FIXME: unsaved documents? } catch (Exception e) { // catch this one here to give a slightly more optimistic error message printErr("could not restart application"); e.printStackTrace(); JOptionPane.showMessageDialog(app.frame, "<html>The restart application could not be lauched:<br><br>" + PApplet.join(cmd, " ") + "<br>" + e.getClass().getName() + ": " + e.getMessage() + "<br><br>" + "However, the update should install automatically when you manually restart the application.</html>", "Slightly disappointing news", JOptionPane.ERROR_MESSAGE); } } public void run() { try { setupEnvironment(); if (checkAndFetch()) askAndAct(); else if (force) JOptionPane.showMessageDialog(app.frame, "No updates available", "Update", JOptionPane.INFORMATION_MESSAGE); } catch (Exception e) { printErr("Something's wrong. Stack trace follows..."); e.printStackTrace(); // prepare error dialog JPanel panel = new JPanel(new BorderLayout(0, 20)); String str = "<html>Something went wrong while checking for updates.<br><br>" + e.getMessage().substring(0, 1).toUpperCase() + e.getMessage().substring(1); if (e.getCause() != null) str += "\ndue to " + e.getCause().getClass().getName() + ": " + e.getCause().getMessage(); str += "</html>"; panel.add(new JLabel(str), BorderLayout.CENTER); JCheckBox cbAutoUpdate = new JCheckBox("Automatically check for updates in the future", app.prefs.getBoolean("update.check", true)); panel.add(cbAutoUpdate, BorderLayout.SOUTH); // show dialog JOptionPane.showMessageDialog(app.frame, panel, "Bollocks!", JOptionPane.ERROR_MESSAGE); // save the auto-check selection app.prefs.putBoolean("update.check", cbAutoUpdate.isSelected()); } } }