diff --git a/apps/i2psnark/java/build.xml b/apps/i2psnark/java/build.xml
index 8323d57f65d0ad0e47ffbe29b39809c6fd322451..4d51cb7426d9374a1867b5a0d1b9e88d4d24149f 100644
--- a/apps/i2psnark/java/build.xml
+++ b/apps/i2psnark/java/build.xml
@@ -53,7 +53,8 @@
      -->
     <target name="war" depends="jar, bundle"> 
         <war destfile="../i2psnark.war" webxml="../web.xml">
-          <classes dir="./build/obj" includes="**/*.class" excludes="**/RunStandalone.class" />
+          <!-- include only the web stuff, as of 0.7.12 the router will add i2psnark.jar to the classpath for the war -->
+          <classes dir="./build/obj" includes="**/web/*.class" />
         </war>
     </target>
     
diff --git a/apps/routerconsole/java/build.xml b/apps/routerconsole/java/build.xml
index d8d85e21dd5f90a42c83433133cb073999041b70..78384113f9d8023b5ab9b7e72bfce5e6f799ecd4 100644
--- a/apps/routerconsole/java/build.xml
+++ b/apps/routerconsole/java/build.xml
@@ -64,13 +64,16 @@
     <target name="jar" depends="compile">
         <jar destfile="./build/routerconsole.jar" basedir="./build/obj" includes="**/*.class">
             <manifest>
-                <attribute name="Class-Path" value="i2p.jar router.jar" />
+                <!-- top level installer will rename to jrobin.jar -->
+                <attribute name="Class-Path" value="i2p.jar router.jar jrobin.jar" />
             </manifest>
         </jar>
         <delete dir="./tmpextract" />
+       <!-- jrobin taken out of routerconsole.jar in 0.7.12
         <unjar src="../../jrobin/jrobin-1.4.0.jar" dest="./tmpextract" />
         <jar destfile="./build/routerconsole.jar" basedir="./tmpextract" update="true" />
         <delete dir="./tmpextract" />
+       -->
         
         <ant target="war" />
 
diff --git a/apps/routerconsole/java/src/net/i2p/router/web/ConfigClientsHandler.java b/apps/routerconsole/java/src/net/i2p/router/web/ConfigClientsHandler.java
index a1204f39339fd6e668637225233a4fa3d5585329..1fd11912652a1341974575510f5d095f3ac84c3a 100644
--- a/apps/routerconsole/java/src/net/i2p/router/web/ConfigClientsHandler.java
+++ b/apps/routerconsole/java/src/net/i2p/router/web/ConfigClientsHandler.java
@@ -1,7 +1,7 @@
 package net.i2p.router.web;
 
 import java.io.File;
-import java.util.Collection;
+import java.io.IOException;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
@@ -13,7 +13,6 @@ import net.i2p.router.startup.ClientAppConfig;
 import net.i2p.router.startup.LoadClientAppsJob;
 import net.i2p.util.Log;
 
-import org.mortbay.http.HttpListener;
 import org.mortbay.jetty.Server;
 
 /**
@@ -37,6 +36,14 @@ public class ConfigClientsHandler extends FormHandler {
             saveWebAppChanges();
             return;
         }
+        if (_action.equals(_("Save Plugin Configuration"))) {
+            savePluginChanges();
+            return;
+        }
+        if (_action.equals(_("Install Plugin"))) {
+            installPlugin();
+            return;
+        }
         // value
         if (_action.startsWith("Start ")) {
             String app = _action.substring(6);
@@ -58,10 +65,48 @@ public class ConfigClientsHandler extends FormHandler {
             try {
                 appnum = Integer.parseInt(app);
             } catch (NumberFormatException nfe) {}
-            if (appnum >= 0)
+            if (appnum >= 0) {
                 deleteClient(appnum);
+            } else {
+                try {
+                    PluginStarter.stopPlugin(_context, app);
+                    PluginStarter.deletePlugin(_context, app);
+                    addFormNotice(_("Deleted plugin {0}", app));
+                } catch (Throwable e) {
+                    addFormError(_("Error deleting plugin {0}", app) + ": " + e);
+                    _log.error("Error deleting plugin " + app,  e);
+                }
+            }
             return;
         }
+
+        // value
+        if (_action.startsWith("Stop ")) {
+            String app = _action.substring(5);
+            try {
+                PluginStarter.stopPlugin(_context, app);
+                addFormNotice(_("Stopped plugin {0}", app));
+            } catch (Throwable e) {
+                addFormError(_("Error stopping plugin {0}", app) + ": " + e);
+                    _log.error("Error stopping plugin " + app,  e);
+            }
+            return;
+        }
+
+        // value
+        if (_action.startsWith("Update ")) {
+            String app = _action.substring(7);
+            updatePlugin(app);
+            return;
+        }
+
+        // value
+        if (_action.startsWith("Check ")) {
+            String app = _action.substring(6);
+            checkPlugin(app);
+            return;
+        }
+
         // label (IE)
         String xStart = _("Start");
         if (_action.toLowerCase().startsWith(xStart + "<span class=hide> ") &&
@@ -79,6 +124,7 @@ public class ConfigClientsHandler extends FormHandler {
         } else {
             addFormError(_("Unsupported") + ' ' + _action + '.');
         }
+
     }
     
     public void setSettings(Map settings) { _settings = new HashMap(settings); }
@@ -173,32 +219,100 @@ public class ConfigClientsHandler extends FormHandler {
                 props.setProperty(name, "" + (val != null));
         }
         RouterConsoleRunner.storeWebAppProperties(props);
-        addFormNotice(_("WebApp configuration saved successfully - restart required to take effect."));
+        addFormNotice(_("WebApp configuration saved."));
     }
 
-    // Big hack for the moment, not using properties for directory and port
-    // Go through all the Jetty servers, find the one serving port 7657,
-    // requested and add the .war to that one
+    private void savePluginChanges() {
+        Properties props = PluginStarter.pluginProperties();
+        Set keys = props.keySet();
+        int cur = 0;
+        for (Iterator iter = keys.iterator(); iter.hasNext(); ) {
+            String name = (String)iter.next();
+            if (! (name.startsWith(PluginStarter.PREFIX) && name.endsWith(PluginStarter.ENABLED)))
+                continue;
+            String app = name.substring(PluginStarter.PREFIX.length(), name.lastIndexOf(PluginStarter.ENABLED));
+            Object val = _settings.get(app + ".enabled");
+            props.setProperty(name, "" + (val != null));
+        }
+        PluginStarter.storePluginProperties(props);
+        addFormNotice(_("Plugin configuration saved."));
+    }
+
+    /**
+     * Big hack for the moment, not using properties for directory and port
+     * Go through all the Jetty servers, find the one serving port 7657,
+     * requested and add the .war to that one
+     */
     private void startWebApp(String app) {
-        Collection c = Server.getHttpServers();
-        for (int i = 0; i < c.size(); i++) {
-            Server s = (Server) c.toArray()[i];
-            HttpListener[] hl = s.getListeners();
-            for (int j = 0; j < hl.length; j++) {
-                if (hl[j].getPort() == 7657) {
+        Server s = PluginStarter.getConsoleServer();
+        if (s != null) {
                     try {
                         File path = new File(_context.getBaseDir(), "webapps");
                         path = new File(path, app + ".war");
-                        s.addWebApplication("/"+ app, path.getAbsolutePath()).start();
-                        // no passwords... initialize(wac);
+                        WebAppStarter.startWebApp(_context, s, app, path.getAbsolutePath());
                         addFormNotice(_("WebApp") + " <a href=\"/" + app + "/\">" + _(app) + "</a> " + _("started") + '.');
-                    } catch (Exception ioe) {
-                        addFormError(_("Failed to start") + ' ' + _(app) + " " + ioe + '.');
+                    } catch (Throwable e) {
+                        addFormError(_("Failed to start") + ' ' + _(app) + " " + e + '.');
+                        _log.error("Failed to start webapp " + app, e);
                     }
                     return;
-                }
-            }
         }
         addFormError(_("Failed to find server."));
     }
+
+    private void installPlugin() {
+        String url = getString("pluginURL");
+        if (url == null || url.length() <= 0) {
+            addFormError(_("No plugin URL specified."));
+            return;
+        }
+        installPlugin(url);
+    }
+
+    private void updatePlugin(String app) {
+        Properties props = PluginStarter.pluginProperties(_context, app);
+        String url = props.getProperty("updateURL");
+        if (url == null) {
+            addFormError(_("No update URL specified for {0}",app));
+            return;
+        }
+        installPlugin(url);
+    }
+
+    private void installPlugin(String url) {
+        if ("true".equals(System.getProperty(UpdateHandler.PROP_UPDATE_IN_PROGRESS))) {
+            addFormError(_("Plugin or update download already in progress."));
+            return;
+        }
+        PluginUpdateHandler puh = PluginUpdateHandler.getInstance(_context);
+        if (puh.isRunning()) {
+            addFormError(_("Plugin or update download already in progress."));
+            return;
+        }
+        puh.update(url);
+        addFormNotice(_("Downloading plugin from {0}", url));
+        // So that update() will post a status to the summary bar before we reload
+        try {
+           Thread.sleep(1000);
+        } catch (InterruptedException ie) {}
+    }
+
+    private void checkPlugin(String app) {
+        if ("true".equals(System.getProperty(UpdateHandler.PROP_UPDATE_IN_PROGRESS))) {
+            addFormError(_("Plugin or update download already in progress."));
+            return;
+        }
+        PluginUpdateChecker puc = PluginUpdateChecker.getInstance(_context);
+        if (puc.isRunning()) {
+            addFormError(_("Plugin or update download already in progress."));
+            return;
+        }
+        puc.update(app);
+        addFormNotice(_("Checking plugin {0} for updates", app));
+        // So that update() will post a status to the summary bar before we reload
+        try {
+           Thread.sleep(1000);
+        } catch (InterruptedException ie) {}
+    }
+
 }
diff --git a/apps/routerconsole/java/src/net/i2p/router/web/ConfigClientsHelper.java b/apps/routerconsole/java/src/net/i2p/router/web/ConfigClientsHelper.java
index acef26f7211c4b70f31882c0e020590abf8a3abc..a096df5f0895e22cffce558c857878a585118564 100644
--- a/apps/routerconsole/java/src/net/i2p/router/web/ConfigClientsHelper.java
+++ b/apps/routerconsole/java/src/net/i2p/router/web/ConfigClientsHelper.java
@@ -1,5 +1,7 @@
 package net.i2p.router.web;
 
+import java.text.SimpleDateFormat;
+import java.util.Date;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Properties;
@@ -33,18 +35,18 @@ public class ConfigClientsHelper extends HelperBase {
     public String getForm1() {
         StringBuilder buf = new StringBuilder(1024);
         buf.append("<table>\n");
-        buf.append("<tr><th align=\"right\">" + _("Client") + "</th><th>" + _("Run at Startup?") + "</th><th>" + _("Start Now") + "</th><th align=\"left\">" + _("Class and arguments") + "</th></tr>\n");
+        buf.append("<tr><th align=\"right\">" + _("Client") + "</th><th>" + _("Run at Startup?") + "</th><th>" + _("Control") + "</th><th align=\"left\">" + _("Class and arguments") + "</th></tr>\n");
         
         List<ClientAppConfig> clients = ClientAppConfig.getClientApps(_context);
         for (int cur = 0; cur < clients.size(); cur++) {
             ClientAppConfig ca = clients.get(cur);
             renderForm(buf, ""+cur, ca.clientName, false, !ca.disabled,
                        "webConsole".equals(ca.clientName) || "Web console".equals(ca.clientName),
-                       ca.className + ((ca.args != null) ? " " + ca.args : ""), (""+cur).equals(_edit), true);
+                       ca.className + ((ca.args != null) ? " " + ca.args : ""), (""+cur).equals(_edit), true, false, false);
         }
         
         if ("new".equals(_edit))
-            renderForm(buf, "" + clients.size(), "", false, false, false, "", true, false);
+            renderForm(buf, "" + clients.size(), "", false, false, false, "", true, false, false, false);
         buf.append("</table>\n");
         return buf.toString();
     }
@@ -52,7 +54,7 @@ public class ConfigClientsHelper extends HelperBase {
     public String getForm2() {
         StringBuilder buf = new StringBuilder(1024);
         buf.append("<table>\n");
-        buf.append("<tr><th align=\"right\">" + _("WebApp") + "</th><th>" + _("Run at Startup?") + "</th><th>" + _("Start Now") + "</th><th align=\"left\">" + _("Description") + "</th></tr>\n");
+        buf.append("<tr><th align=\"right\">" + _("WebApp") + "</th><th>" + _("Run at Startup?") + "</th><th>" + _("Control") + "</th><th align=\"left\">" + _("Description") + "</th></tr>\n");
         Properties props = RouterConsoleRunner.webAppProperties();
         Set<String> keys = new TreeSet(props.keySet());
         for (Iterator<String> iter = keys.iterator(); iter.hasNext(); ) {
@@ -61,7 +63,86 @@ public class ConfigClientsHelper extends HelperBase {
                 String app = name.substring(RouterConsoleRunner.PREFIX.length(), name.lastIndexOf(RouterConsoleRunner.ENABLED));
                 String val = props.getProperty(name);
                 renderForm(buf, app, app, !"addressbook".equals(app),
-                           "true".equals(val), RouterConsoleRunner.ROUTERCONSOLE.equals(app), app + ".war", false, false);
+                           "true".equals(val), RouterConsoleRunner.ROUTERCONSOLE.equals(app), app + ".war", false, false, false, false);
+            }
+        }
+        buf.append("</table>\n");
+        return buf.toString();
+    }
+
+    public boolean showPlugins() {
+        return PluginStarter.pluginsEnabled(_context);
+    }
+
+    public String getForm3() {
+        StringBuilder buf = new StringBuilder(1024);
+        buf.append("<table>\n");
+        buf.append("<tr><th align=\"right\">" + _("Plugin") + "</th><th>" + _("Run at Startup?") + "</th><th>" + _("Control") + "</th><th align=\"left\">" + _("Description") + "</th></tr>\n");
+        Properties props = PluginStarter.pluginProperties();
+        Set<String> keys = new TreeSet(props.keySet());
+        for (Iterator<String> iter = keys.iterator(); iter.hasNext(); ) {
+            String name = iter.next();
+            if (name.startsWith(PluginStarter.PREFIX) && name.endsWith(PluginStarter.ENABLED)) {
+                String app = name.substring(PluginStarter.PREFIX.length(), name.lastIndexOf(PluginStarter.ENABLED));
+                String val = props.getProperty(name);
+                Properties appProps = PluginStarter.pluginProperties(_context, app);
+                StringBuilder desc = new StringBuilder(256);
+                desc.append("<table border=\"0\">")
+                    .append("<tr><td><b>").append(_("Version")).append("<td>").append(stripHTML(appProps, "version"))
+                    .append("<tr><td><b>")
+                    .append(_("Signed by")).append("<td>");
+                String s = stripHTML(appProps, "keyName");
+                if (s.indexOf("@") > 0)
+                    desc.append("<a href=\"mailto:").append(s).append("\">").append(s).append("</a>");
+                else
+                    desc.append(s);
+                s = stripHTML(appProps, "date");
+                if (s != null) {
+                    long ms = 0;
+                    try {
+                        ms = Long.parseLong(s);
+                    } catch (NumberFormatException nfe) {}
+                    if (ms > 0) {
+                        String date = (new SimpleDateFormat("yyyy-MM-dd HH:mm")).format(new Date(ms));
+                        desc.append("<tr><td><b>")
+                            .append(_("Date")).append("<td>").append(date);
+                    }
+                }
+                s = stripHTML(appProps, "author");
+                if (s != null) {
+                    desc.append("<tr><td><b>")
+                        .append(_("Author")).append("<td>");
+                    if (s.indexOf("@") > 0)
+                        desc.append("<a href=\"mailto:").append(s).append("\">").append(s).append("</a>");
+                    else
+                        desc.append(s);
+                }
+                s = stripHTML(appProps, "description_" + Messages.getLanguage(_context));
+                if (s == null)
+                    s = stripHTML(appProps, "description");
+                if (s != null) {
+                    desc.append("<tr><td><b>")
+                        .append(_("Description")).append("<td>").append(s);
+                }
+                s = stripHTML(appProps, "license");
+                if (s != null) {
+                    desc.append("<tr><td><b>")
+                        .append(_("License")).append("<td>").append(s);
+                }
+                s = stripHTML(appProps, "websiteURL");
+                if (s != null) {
+                    desc.append("<tr><td>")
+                        .append("<a href=\"").append(s).append("\">").append(_("Website")).append("</a><td>&nbsp;");
+                }
+                String updateURL = stripHTML(appProps, "updateURL");
+                if (updateURL != null) {
+                    desc.append("<tr><td>")
+                        .append("<a href=\"").append(updateURL).append("\">").append(_("Update link")).append("</a><td>&nbsp;");
+                }
+                desc.append("</table>");
+                renderForm(buf, app, app, false,
+                           "true".equals(val), false, desc.toString(), false, false,
+                           updateURL != null, true);
             }
         }
         buf.append("</table>\n");
@@ -70,7 +151,8 @@ public class ConfigClientsHelper extends HelperBase {
 
     /** ro trumps edit and showEditButton */
     private void renderForm(StringBuilder buf, String index, String name, boolean urlify,
-                            boolean enabled, boolean ro, String desc, boolean edit, boolean showEditButton) {
+                            boolean enabled, boolean ro, String desc, boolean edit,
+                            boolean showEditButton, boolean showUpdateButton, boolean showStopButton) {
         buf.append("<tr><td class=\"mediumtags\" align=\"right\" width=\"25%\">");
         if (urlify && enabled) {
             String link = "/";
@@ -92,14 +174,20 @@ public class ConfigClientsHelper extends HelperBase {
             if (ro)
                 buf.append("disabled=\"true\" ");
         }
-        buf.append("/></td><td align=\"center\" width=\"15%\">");
+        buf.append("></td><td align=\"center\" width=\"15%\">");
         if ((!enabled) && !edit) {
             buf.append("<button type=\"submit\" name=\"action\" value=\"Start ").append(index).append("\" >" + _("Start") + "<span class=hide> ").append(index).append("</span></button>");
         }
-        if (showEditButton && (!edit) && !ro) {
+        if (showEditButton && (!edit) && !ro)
             buf.append("<button type=\"submit\" name=\"edit\" value=\"Edit ").append(index).append("\" >" + _("Edit") + "<span class=hide> ").append(index).append("</span></button>");
-            buf.append("<button type=\"submit\" name=\"action\" value=\"Delete ").append(index).append("\" >" + _("Delete") + "<span class=hide> ").append(index).append("</span></button>");
+        if (showStopButton && (!edit))
+            buf.append("<button type=\"submit\" name=\"action\" value=\"Stop ").append(index).append("\" >" + _("Stop") + "<span class=hide> ").append(index).append("</span></button>");
+        if (showUpdateButton && (!edit) && !ro) {
+            buf.append("<button type=\"submit\" name=\"action\" value=\"Check ").append(index).append("\" >" + _("Check for updates") + "<span class=hide> ").append(index).append("</span></button>");
+            buf.append("<button type=\"submit\" name=\"action\" value=\"Update ").append(index).append("\" >" + _("Update") + "<span class=hide> ").append(index).append("</span></button>");
         }
+        if ((!edit) && !ro)
+            buf.append("<button type=\"submit\" name=\"action\" value=\"Delete ").append(index).append("\" >" + _("Delete") + "<span class=hide> ").append(index).append("</span></button>");
         buf.append("</td><td align=\"left\" width=\"50%\">");
         if (edit && !ro) {
             buf.append("<input type=\"text\" size=\"80\" name=\"desc").append(index).append("\" value=\"");
@@ -110,4 +198,16 @@ public class ConfigClientsHelper extends HelperBase {
         }
         buf.append("</td></tr>\n");
     }
+
+    /**
+     *  Like in DataHelper but doesn't convert null to ""
+     *  There's a lot worse things a plugin could do but...
+     */
+    static String stripHTML(Properties props, String key) {
+        String orig = props.getProperty(key);
+        if (orig == null) return null;
+        String t1 = orig.replace('<', ' ');
+        String rv = t1.replace('>', ' ');
+        return rv;
+    }
 }
diff --git a/apps/routerconsole/java/src/net/i2p/router/web/ConfigUpdateHandler.java b/apps/routerconsole/java/src/net/i2p/router/web/ConfigUpdateHandler.java
index 512471a101ebf4b0fa9b4cb4bc57b1febb0097d5..cea2a11b2fe851eeddf310d5b96a8e3434d72528 100644
--- a/apps/routerconsole/java/src/net/i2p/router/web/ConfigUpdateHandler.java
+++ b/apps/routerconsole/java/src/net/i2p/router/web/ConfigUpdateHandler.java
@@ -65,6 +65,10 @@ public class ConfigUpdateHandler extends FormHandler {
                     addFormNotice(_("Update available, attempting to download now"));
                 else
                     addFormNotice(_("Update available, click button on left to download"));
+                // So that update() will post a status to the summary bar before we reload
+                try {
+                    Thread.sleep(1000);
+                } catch (InterruptedException ie) {}
             } else
                 addFormNotice(_("No update available"));
             return;
diff --git a/apps/routerconsole/java/src/net/i2p/router/web/NavHelper.java b/apps/routerconsole/java/src/net/i2p/router/web/NavHelper.java
index cabb68f156e99c866798de28342684d251439d29..488275a23bee7d8a366eecf3c1d988bc3b575582 100644
--- a/apps/routerconsole/java/src/net/i2p/router/web/NavHelper.java
+++ b/apps/routerconsole/java/src/net/i2p/router/web/NavHelper.java
@@ -1,14 +1,13 @@
 package net.i2p.router.web;
 
-import java.util.HashMap;
 import java.util.Iterator;
 import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
 
+import net.i2p.I2PAppContext;
 
-public class NavHelper extends HelperBase {
-    private static Map _apps = new HashMap();
-    
-    public NavHelper() {}
+public class NavHelper {
+    private static Map<String, String> _apps = new ConcurrentHashMap();
     
     /**
      * To register a new client application so that it shows up on the router
@@ -25,13 +24,16 @@ public class NavHelper extends HelperBase {
         _apps.remove(name);
     }
     
-    public String getClientAppLinks() {
+    /**
+     *  Translated string is loaded by PluginStarter
+     */
+    public static String getClientAppLinks(I2PAppContext ctx) {
         StringBuilder buf = new StringBuilder(1024); 
-        for (Iterator iter = _apps.keySet().iterator(); iter.hasNext(); ) {
-            String name = (String)iter.next();
-            String path = (String)_apps.get(name);
-            buf.append("<a href=\"").append(path).append("\">");
-            buf.append(name).append("</a> |");
+        for (Iterator<String> iter = _apps.keySet().iterator(); iter.hasNext(); ) {
+            String name = iter.next();
+            String path = _apps.get(name);
+            buf.append(" <a target=\"_top\" href=\"").append(path).append("\">");
+            buf.append(name).append("</a>");
         }
         return buf.toString();
     }
diff --git a/apps/routerconsole/java/src/net/i2p/router/web/PluginStarter.java b/apps/routerconsole/java/src/net/i2p/router/web/PluginStarter.java
new file mode 100644
index 0000000000000000000000000000000000000000..76920fc68bc0270fd32ac27ab7b057ff8789cd03
--- /dev/null
+++ b/apps/routerconsole/java/src/net/i2p/router/web/PluginStarter.java
@@ -0,0 +1,418 @@
+package net.i2p.router.web;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.reflect.Method;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.StringTokenizer;
+
+import net.i2p.I2PAppContext;
+import net.i2p.data.DataHelper;
+import net.i2p.router.RouterContext;
+import net.i2p.router.startup.ClientAppConfig;
+import net.i2p.router.startup.LoadClientAppsJob;
+import net.i2p.util.FileUtil;
+import net.i2p.util.Log;
+import net.i2p.util.Translate;
+
+import org.mortbay.http.HttpListener;
+import org.mortbay.jetty.Server;
+
+
+/**
+ *  Start plugins that are already installed
+ *
+ *  @since 0.7.12
+ *  @author zzz
+ */
+public class PluginStarter implements Runnable {
+    private RouterContext _context;
+    static final String PREFIX = "plugin.";
+    static final String ENABLED = ".startOnLoad";
+
+    public PluginStarter(RouterContext ctx) {
+        _context = ctx;
+    }
+
+    static boolean pluginsEnabled(I2PAppContext ctx) {
+         return Boolean.valueOf(ctx.getProperty("router.enablePlugins")).booleanValue();
+    }
+
+    public void run() {
+        startPlugins(_context);
+    }
+
+    /** this shouldn't throw anything */
+    static void startPlugins(RouterContext ctx) {
+        Log log = ctx.logManager().getLog(PluginStarter.class);
+        Properties props = pluginProperties();
+        for (Iterator iter = props.keySet().iterator(); iter.hasNext(); ) {
+            String name = (String)iter.next();
+            if (name.startsWith(PREFIX) && name.endsWith(ENABLED)) {
+                if (Boolean.valueOf(props.getProperty(name)).booleanValue()) {
+                    String app = name.substring(PREFIX.length(), name.lastIndexOf(ENABLED));
+                    try {
+                        if (!startPlugin(ctx, app))
+                            log.error("Failed to start plugin: " + app);
+                    } catch (Throwable e) {
+                        log.error("Failed to start plugin: " + app, e);
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     *  @return true on success
+     *  @throws just about anything, caller would be wise to catch Throwable
+     */
+    static boolean startPlugin(RouterContext ctx, String appName) throws Exception {
+        Log log = ctx.logManager().getLog(PluginStarter.class);
+        File pluginDir = new File(ctx.getAppDir(), PluginUpdateHandler.PLUGIN_DIR + '/' + appName);
+        if ((!pluginDir.exists()) || (!pluginDir.isDirectory())) {
+            log.error("Cannot start nonexistent plugin: " + appName);
+            return false;
+        }
+        //log.error("Starting plugin: " + appName);
+
+        // load and start things in clients.config
+        File clientConfig = new File(pluginDir, "clients.config");
+        if (clientConfig.exists()) {
+            Properties props = new Properties();
+            DataHelper.loadProps(props, clientConfig);
+            List<ClientAppConfig> clients = ClientAppConfig.getClientApps(clientConfig);
+            runClientApps(ctx, pluginDir, clients, "start");
+        }
+
+        // start console webapps in console/webapps
+        Server server = getConsoleServer();
+        if (server != null) {
+            File consoleDir = new File(pluginDir, "console");
+            Properties props = RouterConsoleRunner.webAppProperties(consoleDir.getAbsolutePath());
+            File webappDir = new File(consoleDir, "webapps");
+            String fileNames[] = webappDir.list(RouterConsoleRunner.WarFilenameFilter.instance());
+            if (fileNames != null) {
+                for (int i = 0; i < fileNames.length; i++) {
+                    try {
+                        String warName = fileNames[i].substring(0, fileNames[i].lastIndexOf(".war"));
+                        //log.error("Found webapp: " + warName);
+                        // check for duplicates in $I2P
+                        // easy way for now...
+                        if (warName.equals("i2psnark") || warName.equals("susidns") || warName.equals("i2ptunnel") ||
+                            warName.equals("susimail") || warName.equals("addressbook")) {
+                            log.error("Skipping duplicate webapp " + warName + " in plugin " + appName);
+                            continue;
+                        }
+                        String enabled = props.getProperty(PREFIX + warName + ENABLED);
+                        if (! "false".equals(enabled)) {
+                            //log.error("Starting webapp: " + warName);
+                            String path = new File(webappDir, fileNames[i]).getCanonicalPath();
+                            WebAppStarter.startWebApp(ctx, server, warName, path);
+                        }
+                    } catch (IOException ioe) {
+                        log.error("Error resolving '" + fileNames[i] + "' in '" + webappDir, ioe);
+                    }
+                }
+            }
+        }
+
+        // add translation jars in console/locale
+        // These will not override existing resource bundles since we are adding them
+        // later in the classpath.
+        File localeDir = new File(pluginDir, "console/locale");
+        if (localeDir.exists() && localeDir.isDirectory()) {
+            File[] files = localeDir.listFiles();
+            if (files != null) {
+                boolean added = false;
+                for (int i = 0; i < files.length; i++) {
+                    File f = files[i];
+                    if (f.getName().endsWith(".jar")) {
+                        try {
+                            addPath(f.toURI().toURL());
+                            log.error("INFO: Adding translation plugin to classpath: " + f);
+                            added = true;
+                        } catch (Exception e) {
+                            log.error("Plugin " + appName + " bad classpath element: " + f, e);
+                        }
+                    }
+                }
+                if (added)
+                    Translate.clearCache();
+            }
+        }
+
+        // add themes in console/themes
+
+        // add summary bar link
+        Properties props = pluginProperties(ctx, appName);
+        String name = ConfigClientsHelper.stripHTML(props, "consoleLinkName_" + Messages.getLanguage(ctx));
+        if (name == null)
+            name = ConfigClientsHelper.stripHTML(props, "consoleLinkName");
+        String url = ConfigClientsHelper.stripHTML(props, "consoleLinkURL");
+        if (name != null && url != null && name.length() > 0 && url.length() > 0)
+            NavHelper.registerApp(name, url);
+
+        return true;
+    }
+
+    /**
+     *  @return true on success
+     *  @throws just about anything, caller would be wise to catch Throwable
+     */
+    static boolean stopPlugin(RouterContext ctx, String appName) throws IOException {
+        Log log = ctx.logManager().getLog(PluginStarter.class);
+        File pluginDir = new File(ctx.getAppDir(), PluginUpdateHandler.PLUGIN_DIR + '/' + appName);
+        if ((!pluginDir.exists()) || (!pluginDir.isDirectory())) {
+            log.error("Cannot stop nonexistent plugin: " + appName);
+            return false;
+        }
+
+        // stop things in clients.config
+        File clientConfig = new File(pluginDir, "clients.config");
+        if (clientConfig.exists()) {
+            Properties props = new Properties();
+            DataHelper.loadProps(props, clientConfig);
+            List<ClientAppConfig> clients = ClientAppConfig.getClientApps(clientConfig);
+            runClientApps(ctx, pluginDir, clients, "stop");
+        }
+
+        // stop console webapps in console/webapps
+        Server server = getConsoleServer();
+        if (server != null) {
+            File consoleDir = new File(pluginDir, "console");
+            Properties props = RouterConsoleRunner.webAppProperties(consoleDir.getAbsolutePath());
+            File webappDir = new File(consoleDir, "webapps");
+            String fileNames[] = webappDir.list(RouterConsoleRunner.WarFilenameFilter.instance());
+            if (fileNames != null) {
+                for (int i = 0; i < fileNames.length; i++) {
+                    String warName = fileNames[i].substring(0, fileNames[i].lastIndexOf(".war"));
+                    if (warName.equals("i2psnark") || warName.equals("susidns") || warName.equals("i2ptunnel") ||
+                        warName.equals("susimail") || warName.equals("addressbook")) {
+                        continue;
+                    }
+                    WebAppStarter.stopWebApp(server, warName);
+                }
+            }
+        }
+
+        // remove summary bar link
+        Properties props = pluginProperties(ctx, appName);
+        String name = ConfigClientsHelper.stripHTML(props, "consoleLinkName_" + Messages.getLanguage(ctx));
+        if (name == null)
+            name = ConfigClientsHelper.stripHTML(props, "consoleLinkName");
+        if (name != null && name.length() > 0)
+            NavHelper.unregisterApp(name);
+
+        log.error("Stopping plugin: " + appName);
+        return true;
+    }
+
+    /** @return true on success - call stopPlugin() first */
+    static boolean deletePlugin(RouterContext ctx, String appName) throws IOException {
+        Log log = ctx.logManager().getLog(PluginStarter.class);
+        File pluginDir = new File(ctx.getAppDir(), PluginUpdateHandler.PLUGIN_DIR + '/' + appName);
+        if ((!pluginDir.exists()) || (!pluginDir.isDirectory())) {
+            log.error("Cannot stop nonexistent plugin: " + appName);
+            return false;
+        }
+        // uninstall things in clients.config
+        File clientConfig = new File(pluginDir, "clients.config");
+        if (clientConfig.exists()) {
+            Properties props = new Properties();
+            DataHelper.loadProps(props, clientConfig);
+            List<ClientAppConfig> clients = ClientAppConfig.getClientApps(clientConfig);
+            runClientApps(ctx, pluginDir, clients, "uninstall");
+        }
+        FileUtil.rmdir(pluginDir, false);
+        Properties props = pluginProperties();
+        for (Iterator iter = props.keySet().iterator(); iter.hasNext(); ) {
+            String name = (String)iter.next();
+            if (name.startsWith(PREFIX + appName))
+                iter.remove();
+        }
+        storePluginProperties(props);
+        return true;
+    }
+
+    /** plugin.config */
+    public static Properties pluginProperties(I2PAppContext ctx, String appName) {
+        File cfgFile = new File(ctx.getAppDir(), PluginUpdateHandler.PLUGIN_DIR + '/' + appName + '/' + "plugin.config");
+        Properties rv = new Properties();
+        try {
+            DataHelper.loadProps(rv, cfgFile);
+        } catch (IOException ioe) {}
+        return rv;
+    }
+
+    /**
+     *  plugins.config
+     *  this auto-adds a propery for every dir in the plugin directory
+     */
+    public static Properties pluginProperties() {
+        File dir = I2PAppContext.getGlobalContext().getConfigDir();
+        Properties rv = new Properties();
+        File cfgFile = new File(dir, "plugins.config");
+        
+        try {
+            DataHelper.loadProps(rv, cfgFile);
+        } catch (IOException ioe) {}
+
+        List<String> names = getPlugins();
+        for (String name : names) {
+            String prop = PREFIX + name + ENABLED;
+            if (rv.getProperty(prop) == null)
+                rv.setProperty(prop, "true");
+        }
+        return rv;
+    }
+
+    /**
+     *  all installed plugins whether enabled or not
+     */
+    public static List<String> getPlugins() {
+        List<String> rv = new ArrayList();
+        File pluginDir = new File(I2PAppContext.getGlobalContext().getAppDir(), PluginUpdateHandler.PLUGIN_DIR);
+        File[] files = pluginDir.listFiles();
+        if (files == null)
+            return rv;
+        for (int i = 0; i < files.length; i++) {
+            if (files[i].isDirectory())
+                rv.add(files[i].getName());
+        }
+        return rv;
+    }
+
+    /**
+     *  The signing keys from all the plugins
+     *  @return Map of key to keyname
+     *  Last one wins if a dup (installer should prevent dups)
+     */
+    public static Map<String, String> getPluginKeys(I2PAppContext ctx) {
+        Map<String, String> rv = new HashMap();
+        List<String> names = getPlugins();
+        for (String name : names) {
+            Properties props = pluginProperties(ctx, name);
+            String pubkey = props.getProperty("key");
+            String keyName = props.getProperty("keyName");
+            if (pubkey != null && keyName != null && pubkey.length() == 172 && keyName.length() > 0)
+                rv.put(pubkey, keyName);
+        }
+        return rv;
+    }
+
+    /**
+     *  plugins.config
+     */
+    public static void storePluginProperties(Properties props) {
+        File cfgFile = new File(I2PAppContext.getGlobalContext().getConfigDir(), "plugins.config");
+        try {
+            DataHelper.storeProps(props, cfgFile);
+        } catch (IOException ioe) {}
+    }
+
+    /** see comments in ConfigClientsHandler */
+    static Server getConsoleServer() {
+        Collection c = Server.getHttpServers();
+        for (int i = 0; i < c.size(); i++) {
+            Server s = (Server) c.toArray()[i];
+            HttpListener[] hl = s.getListeners();
+            for (int j = 0; j < hl.length; j++) {
+                if (hl[j].getPort() == 7657)
+                    return s;
+            }
+        }
+        return null;
+    }
+
+    /** @param action "start" or "stop" or "uninstall" */
+    private static void runClientApps(RouterContext ctx, File pluginDir, List<ClientAppConfig> apps, String action) {
+        Log log = ctx.logManager().getLog(PluginStarter.class);
+        for(ClientAppConfig app : apps) {
+            if (action.equals("start") && app.disabled)
+                continue;
+            String argVal[];
+            if (action.equals("start")) {
+                // start
+                argVal = LoadClientAppsJob.parseArgs(app.args);
+            } else {
+                String args;
+                if (action.equals("stop"))
+                    args = app.stopargs;
+                else if (action.equals("uninstall"))
+                    args = app.uninstallargs;
+                else
+                    throw new IllegalArgumentException("bad action");
+                // args must be present
+                if (args == null || args.length() <= 0)
+                    continue;
+                argVal = LoadClientAppsJob.parseArgs(args);
+            }
+            // do this after parsing so we don't need to worry about quoting
+            for (int i = 0; i < argVal.length; i++) {
+                if (argVal[i].indexOf("$") >= 0) {
+                    argVal[i] = argVal[i].replace("$I2P", ctx.getBaseDir().getAbsolutePath());
+                    argVal[i] = argVal[i].replace("$CONFIG", ctx.getConfigDir().getAbsolutePath());
+                    argVal[i] = argVal[i].replace("$PLUGIN", pluginDir.getAbsolutePath());
+                }
+            }
+            if (app.classpath != null) {
+                String cp = new String(app.classpath);
+                if (cp.indexOf("$") >= 0) {
+                    cp = cp.replace("$I2P", ctx.getBaseDir().getAbsolutePath());
+                    cp = cp.replace("$CONFIG", ctx.getConfigDir().getAbsolutePath());
+                    cp = cp.replace("$PLUGIN", pluginDir.getAbsolutePath());
+                }
+                addToClasspath(cp, app.clientName, log);
+            }
+            if (app.delay == 0 || !action.equals("start")) {
+                // run this guy now
+                LoadClientAppsJob.runClient(app.className, app.clientName, argVal, log);
+            } else {
+                // wait before firing it up
+                ctx.jobQueue().addJob(new LoadClientAppsJob.DelayedRunClient(ctx, app.className, app.clientName, argVal, app.delay));
+            }
+        }
+    }
+
+    /**
+     *  Perhaps there's an easy way to use Thread.setContextClassLoader()
+     *  but I don't see how to make it magically get used for everything.
+     *  So add this to the whole JVM's classpath.
+     */
+    private static void addToClasspath(String classpath, String clientName, Log log) {
+        StringTokenizer tok = new StringTokenizer(classpath, ",");
+        while (tok.hasMoreTokens()) {
+            String elem = tok.nextToken().trim();
+            File f = new File(elem);
+            if (!f.isAbsolute()) {
+                log.error("Plugin client " + clientName + " classpath element is not absolute: " + f);
+                continue;
+            }
+            try {
+                addPath(f.toURI().toURL());
+                log.error("INFO: Adding plugin to classpath: " + f);
+            } catch (Exception e) {
+                log.error("Plugin client " + clientName + " bad classpath element: " + f, e);
+            }
+        }
+    }
+
+    /**
+     *  http://jimlife.wordpress.com/2007/12/19/java-adding-new-classpath-at-runtime/
+     */
+    public static void addPath(URL u) throws Exception {
+        URLClassLoader urlClassLoader = (URLClassLoader) ClassLoader.getSystemClassLoader();
+        Class urlClass = URLClassLoader.class;
+        Method method = urlClass.getDeclaredMethod("addURL", new Class[]{URL.class});
+        method.setAccessible(true);
+        method.invoke(urlClassLoader, new Object[]{u});
+    }
+}
diff --git a/apps/routerconsole/java/src/net/i2p/router/web/PluginUpdateChecker.java b/apps/routerconsole/java/src/net/i2p/router/web/PluginUpdateChecker.java
new file mode 100644
index 0000000000000000000000000000000000000000..8b5d2c0147d74f264f5dc81a1eec71ffa7bd54eb
--- /dev/null
+++ b/apps/routerconsole/java/src/net/i2p/router/web/PluginUpdateChecker.java
@@ -0,0 +1,138 @@
+package net.i2p.router.web;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.util.Properties;
+
+import net.i2p.I2PAppContext;
+import net.i2p.crypto.TrustedUpdate;
+import net.i2p.data.DataHelper;
+import net.i2p.router.RouterContext;
+import net.i2p.util.EepGet;
+import net.i2p.util.I2PAppThread;
+import net.i2p.util.Log;
+import net.i2p.util.PartialEepGet;
+import net.i2p.util.VersionComparator;
+
+/**
+ * Download and install a plugin.
+ * A plugin is a standard .sud file with a 40-byte signature,
+ * a 16-byte version, and a .zip file.
+ * Unlike for router updates, we need not have the public key
+ * for the signature in advance.
+ *
+ * The zip file must have a standard directory layout, with
+ * a plugin.config file at the top level.
+ * The config file contains properties for the package name, version,
+ * signing public key, and other settings.
+ * The zip file will typically contain a webapps/ or lib/ dir,
+ * and a webapps.config and/or clients.config file.
+ *
+ * @since 0.7.12
+ * @author zzz
+ */
+public class PluginUpdateChecker extends UpdateHandler {
+    private static PluginUpdateCheckerRunner _pluginUpdateCheckerRunner;
+    private String _appName;
+    private String _oldVersion;
+    private String _xpi2pURL;
+
+    private static PluginUpdateChecker _instance;
+    public static final synchronized PluginUpdateChecker getInstance(RouterContext ctx) { 
+        if (_instance != null)
+            return _instance;
+        _instance = new PluginUpdateChecker(ctx);
+        return _instance;
+    }
+
+    private PluginUpdateChecker(RouterContext ctx) {
+        super(ctx);
+    }
+    
+    public void update(String appName) {
+        // don't block waiting for the other one to finish
+        if ("true".equals(System.getProperty(PROP_UPDATE_IN_PROGRESS))) {
+            _log.error("Update already running");
+            return;
+        }
+        synchronized (UpdateHandler.class) {
+            Properties props = PluginStarter.pluginProperties(_context, appName);
+            String oldVersion = props.getProperty("version");
+            String xpi2pURL = props.getProperty("updateURL");
+            if (oldVersion == null || xpi2pURL == null) {
+                updateStatus("<b>" + _("Cannot check, plugin {0} is not installed", appName) + "</b>");
+                return;
+            }
+
+            if (_pluginUpdateCheckerRunner == null)
+                _pluginUpdateCheckerRunner = new PluginUpdateCheckerRunner();
+            if (_pluginUpdateCheckerRunner.isRunning())
+                return;
+            _xpi2pURL = xpi2pURL;
+            _appName = appName;
+            _oldVersion = oldVersion;
+            System.setProperty(PROP_UPDATE_IN_PROGRESS, "true");
+            I2PAppThread update = new I2PAppThread(_pluginUpdateCheckerRunner, "AppChecker");
+            update.start();
+        }
+    }
+    
+    public boolean isRunning() {
+        return _pluginUpdateCheckerRunner != null && _pluginUpdateCheckerRunner.isRunning();
+    }
+    
+    @Override
+    public boolean isDone() {
+        // FIXME
+        return false;
+    }
+    
+    public class PluginUpdateCheckerRunner extends UpdateRunner implements Runnable, EepGet.StatusListener {
+        ByteArrayOutputStream _baos;
+
+        public PluginUpdateCheckerRunner() { 
+            super();
+            _baos = new ByteArrayOutputStream(TrustedUpdate.HEADER_BYTES);
+        }
+
+        @Override
+        protected void update() {
+            updateStatus("<b>" + _("Checking for update of plugin {0}", _appName) + "</b>");
+            // use the same settings as for updater
+            boolean shouldProxy = Boolean.valueOf(_context.getProperty(ConfigUpdateHandler.PROP_SHOULD_PROXY, ConfigUpdateHandler.DEFAULT_SHOULD_PROXY)).booleanValue();
+            String proxyHost = _context.getProperty(ConfigUpdateHandler.PROP_PROXY_HOST, ConfigUpdateHandler.DEFAULT_PROXY_HOST);
+            int proxyPort = _context.getProperty(ConfigUpdateHandler.PROP_PROXY_PORT, ConfigUpdateHandler.DEFAULT_PROXY_PORT_INT);
+            try {
+                _get = new PartialEepGet(_context, proxyHost, proxyPort, _baos, _xpi2pURL, TrustedUpdate.HEADER_BYTES);
+                _get.addStatusListener(PluginUpdateCheckerRunner.this);
+                _get.fetch();
+            } catch (Throwable t) {
+                _log.error("Error checking update for plugin", t);
+            }
+        }
+        
+        @Override
+        public void bytesTransferred(long alreadyTransferred, int currentWrite, long bytesTransferred, long bytesRemaining, String url) {
+        }
+
+        @Override
+        public void transferComplete(long alreadyTransferred, long bytesTransferred, long bytesRemaining, String url, String outputFile, boolean notModified) {
+            String newVersion = TrustedUpdate.getVersionString(new ByteArrayInputStream(_baos.toByteArray()));
+            boolean newer = (new VersionComparator()).compare(newVersion, _oldVersion) > 0;
+            if (newer)
+                updateStatus("<b>" + _("New plugin version {0} is available", newVersion) + "</b>");
+            else
+                updateStatus("<b>" + _("No new version is available for plugin {0}", _appName) + "</b>");
+        }
+
+        @Override
+        public void transferFailed(String url, long bytesTransferred, long bytesRemaining, int currentAttempt) {
+            File f = new File(_updateFile);
+            f.delete();
+            updateStatus("<b>" + _("Update check failed for plugin {0}", _appName) + "</b>");
+        }
+    }
+}
+    
diff --git a/apps/routerconsole/java/src/net/i2p/router/web/PluginUpdateHandler.java b/apps/routerconsole/java/src/net/i2p/router/web/PluginUpdateHandler.java
new file mode 100644
index 0000000000000000000000000000000000000000..fb54235e4ffa48b6922bf3f8e62f39fa9aea5c32
--- /dev/null
+++ b/apps/routerconsole/java/src/net/i2p/router/web/PluginUpdateHandler.java
@@ -0,0 +1,378 @@
+package net.i2p.router.web;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Map;
+import java.util.Properties;
+
+import net.i2p.CoreVersion;
+import net.i2p.I2PAppContext;
+import net.i2p.crypto.TrustedUpdate;
+import net.i2p.data.DataHelper;
+import net.i2p.router.Router;
+import net.i2p.router.RouterContext;
+import net.i2p.util.EepGet;
+import net.i2p.util.FileUtil;
+import net.i2p.util.I2PAppThread;
+import net.i2p.util.Log;
+import net.i2p.util.OrderedProperties;
+import net.i2p.util.VersionComparator;
+
+/**
+ * Download and install a plugin.
+ * A plugin is a standard .sud file with a 40-byte signature,
+ * a 16-byte version, and a .zip file.
+ * Unlike for router updates, we need not have the public key
+ * for the signature in advance.
+ *
+ * The zip file must have a standard directory layout, with
+ * a plugin.config file at the top level.
+ * The config file contains properties for the package name, version,
+ * signing public key, and other settings.
+ * The zip file will typically contain a webapps/ or lib/ dir,
+ * and a webapps.config and/or clients.config file.
+ *
+ * @since 0.7.12
+ * @author zzz
+ */
+public class PluginUpdateHandler extends UpdateHandler {
+    private static PluginUpdateRunner _pluginUpdateRunner;
+    private String _xpi2pURL;
+    private String _appStatus;
+    private static final String XPI2P = "app.xpi2p";
+    private static final String ZIP = XPI2P + ".zip";
+    public static final String PLUGIN_DIR = "plugins";
+
+    private static PluginUpdateHandler _instance;
+    public static final synchronized PluginUpdateHandler getInstance(RouterContext ctx) { 
+        if (_instance != null)
+            return _instance;
+        _instance = new PluginUpdateHandler(ctx);
+        return _instance;
+    }
+
+    private PluginUpdateHandler(RouterContext ctx) {
+        super(ctx);
+        _appStatus = "";
+    }
+    
+    public void update(String xpi2pURL) {
+        // don't block waiting for the other one to finish
+        if ("true".equals(System.getProperty(PROP_UPDATE_IN_PROGRESS))) {
+            _log.error("Update already running");
+            return;
+        }
+        synchronized (UpdateHandler.class) {
+            if (_pluginUpdateRunner == null)
+                _pluginUpdateRunner = new PluginUpdateRunner(_xpi2pURL);
+            if (_pluginUpdateRunner.isRunning())
+                return;
+            _xpi2pURL = xpi2pURL;
+            _updateFile = (new File(_context.getTempDir(), "tmp" + _context.random().nextInt() + XPI2P)).getAbsolutePath();
+            System.setProperty(PROP_UPDATE_IN_PROGRESS, "true");
+            I2PAppThread update = new I2PAppThread(_pluginUpdateRunner, "AppDownload");
+            update.start();
+        }
+    }
+    
+    public String getAppStatus() {
+        return _appStatus;
+    }
+
+    public boolean isRunning() {
+        return _pluginUpdateRunner != null && _pluginUpdateRunner.isRunning();
+    }
+    
+    @Override
+    public boolean isDone() {
+        // FIXME
+        return false;
+    }
+    
+    public class PluginUpdateRunner extends UpdateRunner implements Runnable, EepGet.StatusListener {
+
+        public PluginUpdateRunner(String url) { 
+            super();
+        }
+
+        @Override
+        protected void update() {
+            updateStatus("<b>" + _("Downloading plugin from {0}", _xpi2pURL) + "</b>");
+            // use the same settings as for updater
+            boolean shouldProxy = Boolean.valueOf(_context.getProperty(ConfigUpdateHandler.PROP_SHOULD_PROXY, ConfigUpdateHandler.DEFAULT_SHOULD_PROXY)).booleanValue();
+            String proxyHost = _context.getProperty(ConfigUpdateHandler.PROP_PROXY_HOST, ConfigUpdateHandler.DEFAULT_PROXY_HOST);
+            int proxyPort = _context.getProperty(ConfigUpdateHandler.PROP_PROXY_PORT, ConfigUpdateHandler.DEFAULT_PROXY_PORT_INT);
+            try {
+                if (shouldProxy)
+                    // 10 retries!!
+                    _get = new EepGet(_context, proxyHost, proxyPort, 10, _updateFile, _xpi2pURL, false);
+                else
+                    _get = new EepGet(_context, 1, _updateFile, _xpi2pURL, false);
+                _get.addStatusListener(PluginUpdateRunner.this);
+                _get.fetch();
+            } catch (Throwable t) {
+                _log.error("Error downloading plugin", t);
+            }
+        }
+        
+        @Override
+        public void bytesTransferred(long alreadyTransferred, int currentWrite, long bytesTransferred, long bytesRemaining, String url) {
+            StringBuilder buf = new StringBuilder(64);
+            buf.append("<b>").append(_("Downloading plugin")).append(' ');
+            double pct = ((double)alreadyTransferred + (double)currentWrite) /
+                         ((double)alreadyTransferred + (double)currentWrite + (double)bytesRemaining);
+            synchronized (_pct) {
+                buf.append(_pct.format(pct));
+            }
+            buf.append(": ");
+            buf.append(_("{0}B transferred", DataHelper.formatSize(currentWrite + alreadyTransferred)));
+            updateStatus(buf.toString());
+        }
+
+        @Override
+        public void transferComplete(long alreadyTransferred, long bytesTransferred, long bytesRemaining, String url, String outputFile, boolean notModified) {
+            updateStatus("<b>" + _("Plugin downloaded") + "</b>");
+            File f = new File(_updateFile);
+            File appDir = new File(_context.getAppDir(), PLUGIN_DIR);
+            if ((!appDir.exists()) && (!appDir.mkdir())) {
+                f.delete();
+                updateStatus("<b>" + _("Cannot create plugin directory {0}", appDir.getAbsolutePath()) + "</b>");
+                return;
+            }
+
+            TrustedUpdate up = new TrustedUpdate(_context);
+            File to = new File(_context.getTempDir(), "tmp" + _context.random().nextInt() + ZIP);
+            // extract to a zip file whether the sig is good or not, so we can get the properties file
+            String err = up.migrateFile(f, to);
+            if (err != null) {
+                updateStatus("<b>" + err + ' ' + _("from {0}", url) + " </b>");
+                f.delete();
+                to.delete();
+                return;
+            }
+            File tempDir = new File(_context.getTempDir(), "tmp" + _context.random().nextInt() + "-unzip");
+            if (!FileUtil.extractZip(to, tempDir)) {
+                f.delete();
+                to.delete();
+                FileUtil.rmdir(tempDir, false);
+                updateStatus("<b>" + _("Plugin from {0} is corrupt", url) + "</b>");
+                return;
+            }
+            File installProps = new File(tempDir, "plugin.config");
+            Properties props = new OrderedProperties();
+            try {
+                DataHelper.loadProps(props, installProps);
+            } catch (IOException ioe) {
+                f.delete();
+                to.delete();
+                FileUtil.rmdir(tempDir, false);
+                updateStatus("<b>" + _("Plugin from {0} does not contain the required configuration file", url) + "</b>");
+                return;
+            }
+            // we don't need this anymore, we will unzip again
+            FileUtil.rmdir(tempDir, false);
+
+            // ok, now we check sigs and deal with a bad sig
+            String pubkey = props.getProperty("key");
+            String keyName = props.getProperty("keyName");
+            if (pubkey == null || keyName == null || pubkey.length() != 172 || keyName.length() <= 0) {
+                f.delete();
+                to.delete();
+                //updateStatus("<b>" + "Plugin contains an invalid key" + ' ' + pubkey + ' ' + keyName + "</b>");
+                updateStatus("<b>" + _("Plugin from {0} contains an invalid key", url) + "</b>");
+                return;
+            }
+
+            // add all existing plugin keys, so any conflicts with existing keys
+            // will be discovered and rejected
+            Map<String, String> existingKeys = PluginStarter.getPluginKeys(_context);
+            for (Map.Entry<String, String> e : existingKeys.entrySet()) {
+                // ignore dups/bad keys
+                up.addKey(e.getKey(), e.getValue());
+            }
+
+            if (up.haveKey(pubkey)) {
+                // the key is already in the TrustedUpdate keyring
+                // verify the sig and verify that it is signed by the keyName in the plugin.config file
+                String signingKeyName = up.verifyAndGetSigner(f);
+                if (!keyName.equals(signingKeyName)) {
+                    f.delete();
+                    to.delete();
+                    updateStatus("<b>" + _("Plugin signature verification of {0} failed", url) + "</b>");
+                    return;
+                }
+            } else {
+                // add to keyring...
+                if(!up.addKey(pubkey, keyName)) {
+                    // bad or duplicate key
+                    f.delete();
+                    to.delete();
+                    updateStatus("<b>" + _("Plugin signature verification of {0} failed", url) + "</b>");
+                    return;
+                }
+                // ...and try the verify again
+                // verify the sig and verify that it is signed by the keyName in the plugin.config file
+                String signingKeyName = up.verifyAndGetSigner(f);
+                if (!keyName.equals(signingKeyName)) {
+                    f.delete();
+                    to.delete();
+                    updateStatus("<b>" + _("Plugin signature verification of {0} failed", url) + "</b>");
+                    return;
+                }
+            }
+
+            String sudVersion = TrustedUpdate.getVersionString(f);
+            f.delete();
+
+            String appName = props.getProperty("name");
+            String version = props.getProperty("version");
+            if (appName == null || version == null || appName.length() <= 0 || version.length() <= 0 ||
+                appName.indexOf("<") >= 0 || appName.indexOf(">") >= 0 ||
+                version.indexOf("<") >= 0 || version.indexOf(">") >= 0 ||
+                appName.startsWith(".") || appName.indexOf("/") >= 0 || appName.indexOf("\\") >= 0) {
+                to.delete();
+                updateStatus("<b>" + _("Plugin from {0} has invalid name or version", url) + "</b>");
+                return;
+            }
+            if (!version.equals(sudVersion)) {
+                to.delete();
+                updateStatus("<b>" + _("Plugin {0} has mismatched versions", appName) + "</b>");
+                return;
+            }
+
+            // todo compare sud version with property version
+
+            String minVersion = ConfigClientsHelper.stripHTML(props, "min-i2p-version");
+            if (minVersion != null &&
+                (new VersionComparator()).compare(CoreVersion.VERSION, minVersion) < 0) {
+                to.delete();
+                updateStatus("<b>" + _("This plugin requires I2P version {0} or higher", minVersion) + "</b>");
+                return;
+            }
+
+            minVersion = ConfigClientsHelper.stripHTML(props, "min-java-version");
+            if (minVersion != null &&
+                (new VersionComparator()).compare(System.getProperty("java.version"), minVersion) < 0) {
+                to.delete();
+                updateStatus("<b>" + _("This plugin requires Java version {0} or higher", minVersion) + "</b>");
+                return;
+            }
+
+            File destDir = new File(appDir, appName);
+            if (destDir.exists()) {
+                if (Boolean.valueOf(props.getProperty("install-only")).booleanValue()) {
+                    to.delete();
+                    updateStatus("<b>" + _("Downloaded plugin is for new installs only, but the plugin is already installed", url) + "</b>");
+                    return;
+                }
+
+                // compare previous version
+                File oldPropFile = new File(destDir, "plugin.config");
+                Properties oldProps = new OrderedProperties();
+                try {
+                    DataHelper.loadProps(oldProps, oldPropFile);
+                } catch (IOException ioe) {
+                    to.delete();
+                    FileUtil.rmdir(tempDir, false);
+                    updateStatus("<b>" + _("Installed plugin does not contain the required configuration file", url) + "</b>");
+                    return;
+                }
+                String oldPubkey = oldProps.getProperty("key");
+                String oldKeyName = oldProps.getProperty("keyName");
+                String oldAppName = props.getProperty("name");
+                if ((!pubkey.equals(oldPubkey)) || (!keyName.equals(oldKeyName)) || (!appName.equals(oldAppName))) {
+                    to.delete();
+                    updateStatus("<b>" + _("Signature of downloaded plugin does not match installed plugin") + "</b>");
+                    return;
+                }
+                String oldVersion = oldProps.getProperty("version");
+                if (oldVersion == null ||
+                    (new VersionComparator()).compare(oldVersion, version) >= 0) {
+                    to.delete();
+                    updateStatus("<b>" + _("Downloaded plugin version {0} is not newer than installed plugin", version) + "</b>");
+                    return;
+                }
+                minVersion = ConfigClientsHelper.stripHTML(props, "min-installed-version");
+                if (minVersion != null &&
+                    (new VersionComparator()).compare(minVersion, oldVersion) > 0) {
+                    to.delete();
+                    updateStatus("<b>" + _("Plugin update requires installed plugin version {0} or higher", minVersion) + "</b>");
+                    return;
+                }
+                String maxVersion = ConfigClientsHelper.stripHTML(props, "max-installed-version");
+                if (maxVersion != null &&
+                    (new VersionComparator()).compare(maxVersion, oldVersion) < 0) {
+                    to.delete();
+                    updateStatus("<b>" + _("Plugin update requires installed plugin version {0} or lower", maxVersion) + "</b>");
+                    return;
+                }
+
+                // check if it is running first?
+                try {
+                    if (!PluginStarter.stopPlugin(_context, appName)) {
+                        // failed, ignore
+                    }
+                } catch (Throwable e) {
+                    // no updateStatus() for this one
+                    _log.error("Error stopping plugin " + appName, e);
+                }
+
+            } else {
+                if (Boolean.valueOf(props.getProperty("update-only")).booleanValue()) {
+                    to.delete();
+                    updateStatus("<b>" + _("Plugin is for upgrades only, but the plugin is not installed") + "</b>");
+                    return;
+                }
+                if (!destDir.mkdir()) {
+                    to.delete();
+                    updateStatus("<b>" + _("Cannot create plugin directory {0}", destDir.getAbsolutePath()) + "</b>");
+                    return;
+                }
+            }
+
+            // Finally, extract the zip to the plugin directory
+            if (!FileUtil.extractZip(to, destDir)) {
+                to.delete();
+                updateStatus("<b>" + _("Failed to install plugin in {0}", destDir.getAbsolutePath()) + "</b>");
+                return;
+            }
+
+            to.delete();
+            if (Boolean.valueOf(props.getProperty("dont-start-at-install")).booleanValue()) {
+                if (Boolean.valueOf(props.getProperty("router-restart-required")).booleanValue())
+                    updateStatus("<b>" + _("Plugin {0} installed, router restart required", appName) + "</b>");
+                else {
+                    updateStatus("<b>" + _("Plugin {0} installed", appName) + "</b>");
+                    Properties pluginProps = PluginStarter.pluginProperties();
+                    pluginProps.setProperty(PluginStarter.PREFIX + appName + PluginStarter.ENABLED, "false");
+                    PluginStarter.storePluginProperties(pluginProps);
+                }
+            } else {
+                // start everything
+                try {
+                    if (PluginStarter.startPlugin(_context, appName))
+                        updateStatus("<b>" + _("Plugin {0} installed and started", appName) + "</b>");
+                    else
+                        updateStatus("<b>" + _("Plugin {0} installed but failed to start, check logs", appName) + "</b>");
+                } catch (Throwable e) {
+                    updateStatus("<b>" + _("Plugin {0} installed but failed to start", appName) + ": " + e + "</b>");
+                    _log.error("Error starting plugin " + appName, e);
+                }
+            }
+        }
+
+        @Override
+        public void transferFailed(String url, long bytesTransferred, long bytesRemaining, int currentAttempt) {
+            File f = new File(_updateFile);
+            f.delete();
+            updateStatus("<b>" + _("Failed to download plugin from {0}", url) + "</b>");
+        }
+    }
+    
+    @Override
+    protected void updateStatus(String s) {
+        super.updateStatus(s);
+        _appStatus = s;
+    }
+}
+    
diff --git a/apps/routerconsole/java/src/net/i2p/router/web/RouterConsoleRunner.java b/apps/routerconsole/java/src/net/i2p/router/web/RouterConsoleRunner.java
index bcd1f3cbd832c30c4b5181825500031e3d96897b..f4f7bd79e0164de5d26fb84ecf6815326fcee21d 100644
--- a/apps/routerconsole/java/src/net/i2p/router/web/RouterConsoleRunner.java
+++ b/apps/routerconsole/java/src/net/i2p/router/web/RouterConsoleRunner.java
@@ -69,6 +69,8 @@ public class RouterConsoleRunner {
         if (!workDirCreated)
             System.err.println("ERROR: Unable to create Jetty temporary work directory");
         
+        // so Jetty can find WebAppConfiguration
+        System.setProperty("jetty.class.path", I2PAppContext.getGlobalContext().getBaseDir() + "/lib/routerconsole.jar");
         _server = new Server();
         boolean rewrite = false;
         Properties props = webAppProperties();
@@ -127,11 +129,9 @@ public class RouterConsoleRunner {
                         String enabled = props.getProperty(PREFIX + appName + ENABLED);
                         if (! "false".equals(enabled)) {
                             String path = new File(dir, fileNames[i]).getCanonicalPath();
-                            wac = _server.addWebApplication("/"+ appName, path);
                             tmpdir = new File(workDir, appName + "-" + _listenPort);
-                            tmpdir.mkdir();
-                            wac.setTempDirectory(tmpdir);
-                            initialize(wac);
+                            WebAppStarter.addWebApp(I2PAppContext.getGlobalContext(), _server, appName, path, tmpdir);
+
                             if (enabled == null) {
                                 // do this so configclients.jsp knows about all apps from reading the config
                                 props.setProperty(PREFIX + appName + ENABLED, "true");
@@ -181,16 +181,22 @@ public class RouterConsoleRunner {
         }
 
         NewsFetcher fetcher = NewsFetcher.getInstance(I2PAppContext.getGlobalContext());
-        Thread t = new I2PAppThread(fetcher, "NewsFetcher");
-        t.setDaemon(true);
+        Thread t = new I2PAppThread(fetcher, "NewsFetcher", true);
+        t.start();
+        
+        t = new I2PAppThread(new StatSummarizer(), "StatSummarizer", true);
         t.start();
         
-        Thread st = new I2PAppThread(new StatSummarizer(), "StatSummarizer");
-        st.setDaemon(true);
-        st.start();
+        List<RouterContext> contexts = RouterContext.listContexts();
+        if (contexts != null) {
+            if (PluginStarter.pluginsEnabled(contexts.get(0))) {
+                t = new I2PAppThread(new PluginStarter(contexts.get(0)), "PluginStarter", true);
+                t.start();
+            }
+        }
     }
     
-    private void initialize(WebApplicationContext context) {
+    static void initialize(WebApplicationContext context) {
         String password = getPassword();
         if (password != null) {
             HashUserRealm realm = new HashUserRealm("i2prouter");
@@ -205,11 +211,11 @@ public class RouterConsoleRunner {
         }
     }
     
-    private String getPassword() {
-        List contexts = RouterContext.listContexts();
+    static String getPassword() {
+        List<RouterContext> contexts = RouterContext.listContexts();
         if (contexts != null) {
             for (int i = 0; i < contexts.size(); i++) {
-                RouterContext ctx = (RouterContext)contexts.get(i);
+                RouterContext ctx = contexts.get(i);
                 String password = ctx.getProperty("consolePassword");
                 if (password != null) {
                     password = password.trim();
@@ -237,10 +243,14 @@ public class RouterConsoleRunner {
 ********/
     
     public static Properties webAppProperties() {
+        return webAppProperties(I2PAppContext.getGlobalContext().getConfigDir().getAbsolutePath());
+    }
+
+    public static Properties webAppProperties(String dir) {
         Properties rv = new Properties();
         // String webappConfigFile = ctx.getProperty(PROP_WEBAPP_CONFIG_FILENAME, DEFAULT_WEBAPP_CONFIG_FILENAME);
         String webappConfigFile = DEFAULT_WEBAPP_CONFIG_FILENAME;
-        File cfgFile = new File(I2PAppContext.getGlobalContext().getConfigDir(), webappConfigFile);
+        File cfgFile = new File(dir, webappConfigFile);
         
         try {
             DataHelper.loadProps(rv, cfgFile);
@@ -263,11 +273,12 @@ public class RouterConsoleRunner {
         }
     }
 
-    private static class WarFilenameFilter implements FilenameFilter {
+    static class WarFilenameFilter implements FilenameFilter {
         private static final WarFilenameFilter _filter = new WarFilenameFilter();
         public static WarFilenameFilter instance() { return _filter; }
         public boolean accept(File dir, String name) {
             return (name != null) && (name.endsWith(".war") && !name.equals(ROUTERCONSOLE + ".war"));
         }
     }
+
 }
diff --git a/apps/routerconsole/java/src/net/i2p/router/web/SummaryBarRenderer.java b/apps/routerconsole/java/src/net/i2p/router/web/SummaryBarRenderer.java
index af3323571de7e634386f991f792e874045cc6e07..153ef06301ce52bf645024a72b93728a5f19a003 100644
--- a/apps/routerconsole/java/src/net/i2p/router/web/SummaryBarRenderer.java
+++ b/apps/routerconsole/java/src/net/i2p/router/web/SummaryBarRenderer.java
@@ -70,7 +70,11 @@ public class SummaryBarRenderer {
                .append(_("Anonymous resident webserver"))
                .append("\">")
                .append(_("Webserver"))
-               .append("</a></td></tr></table>\n" +
+               .append("</a>")
+
+               .append(NavHelper.getClientAppLinks(_context))
+
+               .append("</td></tr></table>\n" +
 
                        "<hr><h3><a href=\"/config.jsp\" target=\"_top\" title=\"")
                .append(_("Configure I2P Router"))
@@ -184,7 +188,7 @@ public class SummaryBarRenderer {
         if (_helper.updateAvailable() || _helper.unsignedUpdateAvailable()) {
             // display all the time so we display the final failure message
             buf.append(UpdateHandler.getStatus());
-            if ("true".equals(System.getProperty("net.i2p.router.web.UpdateHandler.updateInProgress"))) {
+            if ("true".equals(System.getProperty(UpdateHandler.PROP_UPDATE_IN_PROGRESS))) {
                 // nothing
             } else if(
                       // isDone() is always false for now, see UpdateHandler
diff --git a/apps/routerconsole/java/src/net/i2p/router/web/UpdateHandler.java b/apps/routerconsole/java/src/net/i2p/router/web/UpdateHandler.java
index 050f0f975a7fbd53fb2e945b33d4036458ffa400..1f1d3de264c3efab33fa69440104f0ec5bc1d473 100644
--- a/apps/routerconsole/java/src/net/i2p/router/web/UpdateHandler.java
+++ b/apps/routerconsole/java/src/net/i2p/router/web/UpdateHandler.java
@@ -37,7 +37,7 @@ public class UpdateHandler {
     private String _nonce;
     
     protected static final String SIGNED_UPDATE_FILE = "i2pupdate.sud";
-    protected static final String PROP_UPDATE_IN_PROGRESS = "net.i2p.router.web.UpdateHandler.updateInProgress";
+    static final String PROP_UPDATE_IN_PROGRESS = "net.i2p.router.web.UpdateHandler.updateInProgress";
     protected static final String PROP_LAST_UPDATE_TIME = "router.updateLastDownloaded";
 
     public UpdateHandler() {
@@ -124,7 +124,7 @@ public class UpdateHandler {
         protected boolean _isRunning;
         protected boolean done;
         protected EepGet _get;
-        private final DecimalFormat _pct = new DecimalFormat("0.0%");
+        protected final DecimalFormat _pct = new DecimalFormat("0.0%");
 
         public UpdateRunner() { 
             _isRunning = false;
diff --git a/apps/routerconsole/java/src/net/i2p/router/web/WebAppConfiguration.java b/apps/routerconsole/java/src/net/i2p/router/web/WebAppConfiguration.java
new file mode 100644
index 0000000000000000000000000000000000000000..c3fcc334aa2017733429d5af9cfca2b525fc1a81
--- /dev/null
+++ b/apps/routerconsole/java/src/net/i2p/router/web/WebAppConfiguration.java
@@ -0,0 +1,96 @@
+package net.i2p.router.web;
+
+import java.io.File;
+import java.util.Properties;
+import java.util.StringTokenizer;
+
+import net.i2p.I2PAppContext;
+
+import org.mortbay.jetty.servlet.WebApplicationContext;
+
+
+/**
+ *  Add to the webapp classpath as specified in webapps.config.
+ *  This allows us to reference classes that are not in the classpath
+ *  specified in wrapper.config, since old installations have
+ *  individual jars and not lib/*.jar specified in wrapper.config.
+ *
+ *  A sample line in webapps.config is:
+ *     webapps.appname.path=foo.jar,$I2P/lib/bar.jar
+ *  Unless $I2P is specified the path will be relative to $I2P/lib for
+ *  webapps in the installation and appDir/plugins/appname/lib for plugins.
+ *
+ *  Sadly, setting Class-Path in MANIFEST.MF doesn't work for jetty wars.
+ *  We could look there ourselves, or look for another properties file in the war,
+ *  but let's just do it in webapps.config.
+ *
+ *  No, wac.addClassPath() does not work. For more info see:
+ *
+ *  http://servlets.com/archive/servlet/ReadMsg?msgId=511113&listName=jetty-support
+ *
+ *  @since 0.7.12
+ *  @author zzz
+ */
+public class WebAppConfiguration implements WebApplicationContext.Configuration {
+    private WebApplicationContext _wac;
+
+    private static final String CLASSPATH = ".classpath";
+
+    public void setWebApplicationContext(WebApplicationContext context) {
+       _wac = context;
+    }
+
+    public WebApplicationContext getWebApplicationContext() {
+        return _wac;
+    }
+
+    public void configureClassPath() throws Exception {
+        String ctxPath = _wac.getContextPath();
+        //System.err.println("Configure Class Path " + ctxPath);
+        if (ctxPath.equals("/"))
+            return;
+        String appName = ctxPath.substring(1);
+
+        I2PAppContext i2pContext = I2PAppContext.getGlobalContext();
+        File libDir = new File(i2pContext.getBaseDir(), "lib");
+        // FIXME this only works if war is the same name as the plugin
+        File pluginDir = new File(i2pContext.getAppDir(),
+                                        PluginUpdateHandler.PLUGIN_DIR + ctxPath);
+
+        File dir = libDir;
+        String cp;
+        if (ctxPath.equals("/susidns")) {
+            // jars moved from the .war to lib/ in 0.7.12
+            cp = "jstl.jar,standard.jar";
+        } else if (ctxPath.equals("/i2psnark")) {
+            // duplicate classes removed from the .war in 0.7.12
+            cp = "i2psnark.jar";
+        } else if (pluginDir.exists()) {
+            File consoleDir = new File(pluginDir, "console");
+            Properties props = RouterConsoleRunner.webAppProperties(consoleDir.getAbsolutePath());
+            cp = props.getProperty(RouterConsoleRunner.PREFIX + appName + CLASSPATH);
+            dir = pluginDir;
+        } else {
+            Properties props = RouterConsoleRunner.webAppProperties();
+            cp = props.getProperty(RouterConsoleRunner.PREFIX + appName + CLASSPATH);
+        }
+        if (cp == null)
+            return;
+        StringTokenizer tok = new StringTokenizer(cp, " ,");
+        while (tok.hasMoreTokens()) {
+            String elem = tok.nextToken().trim();
+            String path;
+            if (elem.startsWith("$I2P"))
+                path = i2pContext.getBaseDir().getAbsolutePath() + elem.substring(4);
+            else if (elem.startsWith("$PLUGIN"))
+                path = dir.getAbsolutePath() + elem.substring(7);
+            else
+                path = dir.getAbsolutePath() + '/' + elem;
+            System.err.println("Adding " + path + " to classpath for " + appName);
+            _wac.addClassPath(path);
+        }
+    }
+
+    public void configureDefaults() {}
+    public void configureWebApp() {}
+}
diff --git a/apps/routerconsole/java/src/net/i2p/router/web/WebAppStarter.java b/apps/routerconsole/java/src/net/i2p/router/web/WebAppStarter.java
new file mode 100644
index 0000000000000000000000000000000000000000..1117fd93abdaaa9e2ba7574a6231cd913a805aa0
--- /dev/null
+++ b/apps/routerconsole/java/src/net/i2p/router/web/WebAppStarter.java
@@ -0,0 +1,76 @@
+package net.i2p.router.web;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Properties;
+import java.util.StringTokenizer;
+
+import net.i2p.I2PAppContext;
+
+import org.mortbay.http.HttpContext;
+import org.mortbay.jetty.Server;
+import org.mortbay.jetty.servlet.WebApplicationContext;
+
+
+/**
+ *  Start a webappapp classpath as specified in webapps.config.
+ *
+ *  Sadly, setting Class-Path in MANIFEST.MF doesn't work for jetty wars.
+ *  We could look there ourselves, or look for another properties file in the war,
+ *  but let's just do it in webapps.config.
+ *
+ *  No, wac.addClassPath() does not work.
+ *
+ *  http://servlets.com/archive/servlet/ReadMsg?msgId=511113&listName=jetty-support
+ *
+ *  @since 0.7.12
+ *  @author zzz
+ */
+public class WebAppStarter {
+
+    /**
+     *  adds and starts
+     *  @throws just about anything, caller would be wise to catch Throwable
+     */
+    static void startWebApp(I2PAppContext ctx, Server server, String appName, String warPath) throws Exception {
+         File tmpdir = new File(ctx.getTempDir(), "jetty-work-" + appName + ctx.random().nextInt());
+         WebApplicationContext wac = addWebApp(ctx, server, appName, warPath, tmpdir);
+         wac.start();
+    }
+
+    /**
+     *  add but don't start
+     */
+    static WebApplicationContext addWebApp(I2PAppContext ctx, Server server, String appName, String warPath, File tmpdir) throws IOException {
+
+        WebApplicationContext wac = server.addWebApplication("/"+ appName, warPath);
+        tmpdir.mkdir();
+        wac.setTempDirectory(tmpdir);
+
+        // this does the passwords...
+        RouterConsoleRunner.initialize(wac);
+
+        // see WebAppConfiguration for info
+        String[] classNames = server.getWebApplicationConfigurationClassNames();
+        String[] newClassNames = new String[classNames.length + 1];
+        for (int j = 0; j < classNames.length; j++)
+             newClassNames[j] = classNames[j];
+        newClassNames[classNames.length] = WebAppConfiguration.class.getName();
+        wac.setConfigurationClassNames(newClassNames);
+        return wac;
+    }
+
+    /**
+     *  stop it
+     *  @throws just about anything, caller would be wise to catch Throwable
+     */
+    static void stopWebApp(Server server, String appName) {
+        // this will return a new context if one does not exist
+        HttpContext wac = server.getContext('/' + appName);
+        try {
+            // false -> not graceful
+            wac.stop(false);
+        } catch (InterruptedException ie) {}
+    }
+
+}
diff --git a/apps/routerconsole/jsp/configclients.jsp b/apps/routerconsole/jsp/configclients.jsp
index 2af8ab782b2b833c09bd9b8a0b465f18247929eb..4bb17007c6bf500d4dc523badff91afb5f1a6973 100644
--- a/apps/routerconsole/jsp/configclients.jsp
+++ b/apps/routerconsole/jsp/configclients.jsp
@@ -54,4 +54,20 @@ button span.hide{
  <i><%=intl._("All changes require restart to take effect.")%></i>
  </p><hr><div class="formaction">
  <input type="submit" name="action" value="<%=intl._("Save WebApp Configuration")%>" />
- </div></div></form></div></div></body></html>
+</div></div>
+<% if (clientshelper.showPlugins()) { %>
+<h3><a name="webapp"></a><%=intl._("Plugin Configuration")%></h3><p>
+ <%=intl._("The plugins listed below are started by the webConsole client.")%>
+ </p><div class="wideload"><p>
+ <jsp:getProperty name="clientshelper" property="form3" />
+ </p><hr><div class="formaction">
+ <input type="submit" name="action" value="<%=intl._("Save Plugin Configuration")%>" />
+</div></div><h3><a name="plugin"></a><%=intl._("Plugin Installation")%></h3><p>
+ <%=intl._("To install a plugin, enter the download URL:")%>
+ </p><div class="wideload"><p>
+ <input type="text" size="60" name="pluginURL" >
+ </p><hr><div class="formaction">
+ <input type="submit" name="action" value="<%=intl._("Install Plugin")%>" />
+ </div></div>
+<% } %>
+</form></div></div></body></html>
diff --git a/apps/susidns/src/build.xml b/apps/susidns/src/build.xml
index b0f7e96cf394f08b7f8c86f4233b047b405b1bd5..55b662a9d380184965e0db1f8d3e9bc23e36351f 100644
--- a/apps/susidns/src/build.xml
+++ b/apps/susidns/src/build.xml
@@ -66,7 +66,9 @@
         <war destfile="${project}.war" webxml="WEB-INF/web-out.xml">
         	<fileset dir=".">
         		<include name="WEB-INF/**/*.class"/>
+                     <!-- pulled out of the jar in 0.7.12
         		<include name="WEB-INF/lib/*.jar"/>
+                      -->
         		<include name="images/*.png"/>
         		<include name="css.css"/>
         		<include name="index.html"/>
diff --git a/build.xml b/build.xml
index a7209a45f7d5689f6fa4a58a089af41a3e8f7458..c2e2481c488ed41e50f4cb08f307f90b7ef5b13e 100644
--- a/build.xml
+++ b/build.xml
@@ -314,6 +314,8 @@
         <copy file="build/org.mortbay.jetty.jar" todir="pkg-temp/lib/" />
         <copy file="build/router.jar" todir="pkg-temp/lib/" />
         <copy file="build/routerconsole.jar" todir="pkg-temp/lib/" />
+        <!-- pulled out of routerconsole.jar in 0.7.12; name without version so we can overwrite if we upgrade  -->
+        <copy file="apps/jrobin/jrobin-1.4.0.jar" tofile="pkg-temp/lib/jrobin.jar" />
         <copy file="build/sam.jar" todir="pkg-temp/lib/" />
         <copy file="build/BOB.jar" todir="pkg-temp/lib/" />
         <copy file="build/systray.jar" todir="pkg-temp/lib" />
@@ -325,6 +327,8 @@
         <copy file="build/addressbook.war" todir="pkg-temp/webapps/" />
         <copy file="build/susimail.war" todir="pkg-temp/webapps/" />
         <copy file="build/susidns.war" todir="pkg-temp/webapps/" />
+        <copy file="apps/susidns/src/WEB-INF/lib/jstl.jar" todir="pkg-temp/lib/" />
+        <copy file="apps/susidns/src/WEB-INF/lib/standard.jar" todir="pkg-temp/lib/" />
         <copy file="build/i2psnark.war" todir="pkg-temp/webapps/" />
         <copy file="apps/i2psnark/launch-i2psnark" todir="pkg-temp/" />
         <copy file="apps/i2psnark/jetty-i2psnark.xml" todir="pkg-temp/" />
@@ -470,6 +474,9 @@
         <copy file="build/systray.jar" todir="pkg-temp/lib/" />
         <copy file="build/susimail.war" todir="pkg-temp/webapps/" />
         <copy file="build/susidns.war" todir="pkg-temp/webapps/" />
+        <!-- as of 0.7.12; someday, we can remove these from the updater -->
+        <copy file="apps/susidns/src/WEB-INF/lib/jstl.jar" todir="pkg-temp/lib/" />
+        <copy file="apps/susidns/src/WEB-INF/lib/standard.jar" todir="pkg-temp/lib/" />
         <copy file="build/i2psnark.war" todir="pkg-temp/webapps/" />
         <copy file="history.txt" todir="pkg-temp/" />
         <!-- the following overwrites history.txt on unix to shrink the update file -->
@@ -489,6 +496,9 @@
         <copy file="build/mstreaming.jar" todir="pkg-temp/lib/" />
         <copy file="build/streaming.jar" todir="pkg-temp/lib/" />
         <copy file="build/routerconsole.jar" todir="pkg-temp/lib/" />
+        <!-- pulled out of routerconsole.jar in 0.7.12, someday we can take out of updater -->
+        <!-- name without version so we can overwrite if we upgrade  -->
+        <copy file="apps/jrobin/jrobin-1.4.0.jar" tofile="pkg-temp/lib/jrobin.jar" />
         <copy file="build/i2ptunnel.war" todir="pkg-temp/webapps/" />
         <copy file="build/routerconsole.war" todir="pkg-temp/webapps/" />
         <copy file="build/addressbook.war" todir="pkg-temp/webapps/" />
diff --git a/core/java/src/net/i2p/crypto/TrustedUpdate.java b/core/java/src/net/i2p/crypto/TrustedUpdate.java
index 3d5aa0c1e1f54316acc74f72ddfa309e29fb87a5..054d15358cf37b5c33d9246ee4c8afb45d3bf2db 100644
--- a/core/java/src/net/i2p/crypto/TrustedUpdate.java
+++ b/core/java/src/net/i2p/crypto/TrustedUpdate.java
@@ -4,6 +4,7 @@ import java.io.ByteArrayInputStream;
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.FileOutputStream;
+import java.io.InputStream;
 import java.io.IOException;
 import java.io.SequenceInputStream;
 import java.io.UnsupportedEncodingException;
@@ -104,7 +105,7 @@ D8usM7Dxp5yrDrCYZ5AIijc=
 */
 
     private static final int    VERSION_BYTES       = 16;
-    private static final int    HEADER_BYTES        = Signature.SIGNATURE_BYTES + VERSION_BYTES;
+    public static final int    HEADER_BYTES        = Signature.SIGNATURE_BYTES + VERSION_BYTES;
     private static final String PROP_TRUSTED_KEYS   = "router.trustedUpdateKeys";
 
     private static I2PAppContext _context;
@@ -178,6 +179,22 @@ D8usM7Dxp5yrDrCYZ5AIijc=
         return true;
     }
 
+    /**
+     *  Do we know about the following key?
+     *  @since 0.7.12
+     */
+    public boolean haveKey(String key) {
+        if (key.length() != KEYSIZE_B64_BYTES)
+            return false;
+        SigningPublicKey signingPublicKey = new SigningPublicKey();
+        try {
+            signingPublicKey.fromBase64(key);
+        } catch (DataFormatException dfe) {
+            return false;
+        }
+        return _trustedKeys.containsKey(signingPublicKey);
+    }
+
     /**
      * Parses command line arguments when this class is used from the command
      * line.
@@ -258,7 +275,7 @@ D8usM7Dxp5yrDrCYZ5AIijc=
     }
 
     private static final void showVersionCLI(String signedFile) {
-        String versionString = new TrustedUpdate().getVersionString(new File(signedFile));
+        String versionString = getVersionString(new File(signedFile));
 
         if (versionString.equals(""))
             System.out.println("No version string found in file '" + signedFile + "'");
@@ -331,7 +348,7 @@ D8usM7Dxp5yrDrCYZ5AIijc=
      * @return The version string read, or an empty string if no version string
      *         is present.
      */
-    public String getVersionString(File signedFile) {
+    public static String getVersionString(File signedFile) {
         FileInputStream fileInputStream = null;
 
         try {
@@ -364,6 +381,45 @@ D8usM7Dxp5yrDrCYZ5AIijc=
                 }
         }
     }
+    
+    /**
+     * Reads the version string from an input stream
+     * 
+     * @param inputStream containing at least 56 bytes
+     * 
+     * @return The version string read, or an empty string if no version string
+     *         is present.
+     */
+    public static String getVersionString(InputStream inputStream) {
+        try {
+            long skipped = inputStream.skip(Signature.SIGNATURE_BYTES);
+            if (skipped != Signature.SIGNATURE_BYTES)
+                return "";
+            byte[] data = new byte[VERSION_BYTES];
+            int bytesRead = DataHelper.read(inputStream, data);
+
+            if (bytesRead != VERSION_BYTES) {
+                return "";
+            }
+
+            for (int i = 0; i < VERSION_BYTES; i++) 
+                if (data[i] == 0x00) {
+                    return new String(data, 0, i, "UTF-8");
+                }
+
+            return new String(data, "UTF-8");
+        } catch (UnsupportedEncodingException uee) {
+            throw new RuntimeException("wtf, your JVM doesnt support utf-8? " + uee.getMessage());
+        } catch (IOException ioe) {
+            return "";
+        } finally {
+            if (inputStream != null)
+                try {
+                    inputStream.close();
+                } catch (IOException ioe) {
+                }
+        }
+    }
 
     /** version in the .sud file, valid only after calling migrateVerified() */
     public String newVersion() {
@@ -410,6 +466,22 @@ D8usM7Dxp5yrDrCYZ5AIijc=
         if (!verify(signedFile))
             return "Unknown signing key or corrupt file";
 
+        return migrateFile(signedFile, outputFile);
+    }
+
+    /**
+     * Extract the file. Skips and ignores the signature and version. No verification.
+     * 
+     * @param signedFile     A signed update file.
+     * @param outputFile     The file to write the verified data to.
+     * 
+     * @return <code>null</code> if the
+     *         data was moved, and an error <code>String</code> otherwise.
+     */
+    public String migrateFile(File signedFile, File outputFile) {
+        if (!signedFile.exists())
+            return "File not found: " + signedFile.getAbsolutePath();
+
         FileInputStream fileInputStream = null;
         FileOutputStream fileOutputStream = null;
 
@@ -610,6 +682,23 @@ D8usM7Dxp5yrDrCYZ5AIijc=
         return false;
     }
 
+    /**
+     * Verifies the DSA signature of a signed update file.
+     * 
+     * @param signedFile The signed update file to check.
+     * 
+     * @return signer (could be empty string) or null if invalid
+     * @since 0.7.12
+     */
+    public String verifyAndGetSigner(File signedFile) {
+        for (SigningPublicKey signingPublicKey : _trustedKeys.keySet()) {
+            boolean isValidSignature = verify(signedFile, signingPublicKey);
+            if (isValidSignature)
+                return _trustedKeys.get(signingPublicKey);
+        }
+        return null;
+    }
+
     /**
      * Verifies the DSA signature of a signed update file.
      * 
diff --git a/core/java/src/net/i2p/util/PartialEepGet.java b/core/java/src/net/i2p/util/PartialEepGet.java
new file mode 100644
index 0000000000000000000000000000000000000000..5b18bc7619ffbdc4ebe2e749243d5f9186735e04
--- /dev/null
+++ b/core/java/src/net/i2p/util/PartialEepGet.java
@@ -0,0 +1,130 @@
+package net.i2p.util;
+
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.URL;
+
+import net.i2p.I2PAppContext;
+
+/**
+ * Fetch exactly the first 'size' bytes into a stream
+ * Anything less or more will throw an IOException
+ * No retries, no min and max size options, no timeout option
+ * Useful for checking .sud versions
+ *
+ * @since 0.7.12
+ * @author zzz
+ */
+public class PartialEepGet extends EepGet {
+    long _fetchSize;
+
+    /** @param size fetch exactly this many bytes */
+    public PartialEepGet(I2PAppContext ctx, String proxyHost, int proxyPort,
+                         OutputStream outputStream,  String url, long size) {
+        // we're using this constructor:
+        // public EepGet(I2PAppContext ctx, boolean shouldProxy, String proxyHost, int proxyPort, int numRetries, long minSize, long maxSize, String outputFile, OutputStream outputStream, String url, boolean allowCaching, String etag, String postData) {
+        super(ctx, true, proxyHost, proxyPort, 0, size, size, null, outputStream, url, true, null, null);
+        _fetchSize = size;
+    }
+   
+    /**
+     * PartialEepGet [-p 127.0.0.1:4444] [-l #bytes] url
+     *
+     */ 
+    public static void main(String args[]) {
+        String proxyHost = "127.0.0.1";
+        int proxyPort = 4444;
+        // 40 sig + 16 version for .suds
+        long size = 56;
+        String url = null;
+        try {
+            for (int i = 0; i < args.length; i++) {
+                if (args[i].equals("-p")) {
+                    proxyHost = args[i+1].substring(0, args[i+1].indexOf(':'));
+                    String port = args[i+1].substring(args[i+1].indexOf(':')+1);
+                    proxyPort = Integer.parseInt(port);
+                    i++;
+                } else if (args[i].equals("-l")) {
+                    size = Long.parseLong(args[i+1]);
+                    i++;
+                } else if (args[i].startsWith("-")) {
+                    usage();
+                    return;
+                } else {
+                    url = args[i];
+                }
+            }
+        } catch (Exception e) {
+            e.printStackTrace();
+            usage();
+            return;
+        }
+        
+        if (url == null) {
+            usage();
+            return;
+        }
+
+        String saveAs = suggestName(url);
+        OutputStream out;
+        try {
+            // resume from a previous eepget won't work right doing it this way
+            out = new FileOutputStream(saveAs);
+        } catch (IOException ioe) {
+            System.err.println("Failed to create output file " + saveAs);
+            return;
+        }
+
+        EepGet get = new PartialEepGet(I2PAppContext.getGlobalContext(), proxyHost, proxyPort, out, url, size);
+        get.addStatusListener(get.new CLIStatusListener(1024, 40));
+        if (get.fetch(45*1000, -1, 60*1000)) {
+            System.err.println("Last-Modified: " + get.getLastModified());
+            System.err.println("Etag: " + get.getETag());
+        } else {
+            System.err.println("Failed " + url);
+        }
+    }
+    
+    private static void usage() {
+        System.err.println("PartialEepGet [-p 127.0.0.1:4444] [-l #bytes] url");
+    }
+    
+    @Override
+    protected String getRequest() throws IOException {
+        StringBuilder buf = new StringBuilder(2048);
+        URL url = new URL(_actualURL);
+        String proto = url.getProtocol();
+        String host = url.getHost();
+        int port = url.getPort();
+        String path = url.getPath();
+        String query = url.getQuery();
+        if (query != null)
+            path = path + '?' + query;
+        if (!path.startsWith("/"))
+	    path = "/" + path;
+        if ( (port == 80) || (port == 443) || (port <= 0) ) path = proto + "://" + host + path;
+        else path = proto + "://" + host + ":" + port + path;
+        if (_log.shouldLog(Log.DEBUG)) _log.debug("Requesting " + path);
+        buf.append("GET ").append(_actualURL).append(" HTTP/1.1\r\n");
+        buf.append("Host: ").append(url.getHost()).append("\r\n");
+        buf.append("Range: bytes=");
+        buf.append(_alreadyTransferred);
+        buf.append('-');
+        buf.append(_fetchSize - 1);
+        buf.append("\r\n");
+
+        if (_shouldProxy)
+            buf.append("X-Accept-Encoding: x-i2p-gzip;q=1.0, identity;q=0.5, deflate;q=0, gzip;q=0, *;q=0\r\n");
+        buf.append("Cache-control: no-cache\r\n" +
+                   "Pragma: no-cache\r\n");
+        // This will be replaced if we are going through I2PTunnelHTTPClient
+        buf.append("User-Agent: " + USER_AGENT + "\r\n" +
+                   "Accept-Encoding: \r\n" +
+                   "Connection: close\r\n\r\n");
+
+        if (_log.shouldLog(Log.DEBUG))
+            _log.debug("Request: [" + buf.toString() + "]");
+        return buf.toString();
+    }
+}
diff --git a/core/java/src/net/i2p/util/Translate.java b/core/java/src/net/i2p/util/Translate.java
index c9072b62f5f7e79ff1c0db8ad5c029254f19cb97..799b89c00cc2e0b5e0c5af4f22cfc7e21a93f4fb 100644
--- a/core/java/src/net/i2p/util/Translate.java
+++ b/core/java/src/net/i2p/util/Translate.java
@@ -126,4 +126,13 @@ public abstract class Translate {
         }
         return rv;
     }
+
+    /**
+     *  Clear the cache.
+     *  Call this after adding new bundles to the classpath.
+     *  @since 0.7.12
+     */
+    public static void clearCache() {
+        _missing.clear();
+    }
 }
diff --git a/core/java/src/net/i2p/util/VersionComparator.java b/core/java/src/net/i2p/util/VersionComparator.java
index 3b643442eff823b6d7a25b9e49748063ef113f35..4c4400f3de4e59fab4607c2b9fe5404ea1efaa4b 100644
--- a/core/java/src/net/i2p/util/VersionComparator.java
+++ b/core/java/src/net/i2p/util/VersionComparator.java
@@ -5,7 +5,8 @@ import java.util.StringTokenizer;
 
 /**
  * Compares versions.
- * Characters other than [0-9.] are ignored.
+ * Characters other than [0-9.-_] are ignored.
+ * I2P only uses '.' but Sun Java uses '_' and plugins may use any of '.-_'
  * Moved from TrustedUpdate.java
  * @since 0.7.10
  */
@@ -15,8 +16,8 @@ public class VersionComparator implements Comparator<String> {
         // try it the easy way first
         if (l.equals(r))
             return 0;
-        StringTokenizer lTokens = new StringTokenizer(sanitize(l), ".");
-        StringTokenizer rTokens = new StringTokenizer(sanitize(r), ".");
+        StringTokenizer lTokens = new StringTokenizer(sanitize(l), VALID_SEPARATOR_CHARS);
+        StringTokenizer rTokens = new StringTokenizer(sanitize(r), VALID_SEPARATOR_CHARS);
 
         while (lTokens.hasMoreTokens() && rTokens.hasMoreTokens()) {
             String lNumber = lTokens.nextToken();
@@ -48,7 +49,8 @@ public class VersionComparator implements Comparator<String> {
         return left - right;
     }
 
-    private static final String VALID_VERSION_CHARS = "0123456789.";
+    private static final String VALID_SEPARATOR_CHARS = ".-_";
+    private static final String VALID_VERSION_CHARS = "0123456789" + VALID_SEPARATOR_CHARS;
 
     private static final String sanitize(String versionString) {
         StringBuilder versionStringBuilder = new StringBuilder(versionString);
diff --git a/router/java/src/net/i2p/router/RouterContext.java b/router/java/src/net/i2p/router/RouterContext.java
index dfa9d7c210dc95fddaa2ad4b3718e803ede9e5ed..b4fa7538124a57f0082f89a6634e9048942bfb21 100644
--- a/router/java/src/net/i2p/router/RouterContext.java
+++ b/router/java/src/net/i2p/router/RouterContext.java
@@ -62,7 +62,7 @@ public class RouterContext extends I2PAppContext {
     private Calculator _capacityCalc;
 
 
-    private static List _contexts = new ArrayList(1);
+    private static List<RouterContext> _contexts = new ArrayList(1);
     
     public RouterContext(Router router) { this(router, null); }
     public RouterContext(Router router, Properties envProps) { 
@@ -148,7 +148,7 @@ public class RouterContext extends I2PAppContext {
      * context is created or a router is shut down.
      *
      */
-    public static List listContexts() { return _contexts; }
+    public static List<RouterContext> listContexts() { return _contexts; }
     
     /** what router is this context working for? */
     public Router router() { return _router; }
diff --git a/router/java/src/net/i2p/router/startup/ClientAppConfig.java b/router/java/src/net/i2p/router/startup/ClientAppConfig.java
index 54342a8951dd7bbdc9098cf05038844cb5b6b9c5..4c5125bc2cc080b73148ddd22feab7089416a6d2 100644
--- a/router/java/src/net/i2p/router/startup/ClientAppConfig.java
+++ b/router/java/src/net/i2p/router/startup/ClientAppConfig.java
@@ -4,6 +4,7 @@ import java.io.File;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.Properties;
 
@@ -32,6 +33,13 @@ public class ClientAppConfig {
     public String args;
     public long delay;
     public boolean disabled;
+    /** @since 0.7.12 */
+    public String classpath;
+    /** @since 0.7.12 */
+    public String stopargs;
+    /** @since 0.7.12 */
+    public String uninstallargs;
+
     public ClientAppConfig(String cl, String client, String a, long d, boolean dis) {
         className = cl;
         clientName = client;
@@ -40,6 +48,14 @@ public class ClientAppConfig {
         disabled = dis;
     }
 
+    /** @since 0.7.12 */
+    public ClientAppConfig(String cl, String client, String a, long d, boolean dis, String cp, String sa, String ua) {
+        this(cl, client, a, d, dis);
+        classpath = cp;
+        stopargs = sa;
+        uninstallargs = ua;
+    }
+
     public static File configFile(I2PAppContext ctx) {
         String clientConfigFile = ctx.getProperty(PROP_CLIENT_CONFIG_FILENAME, DEFAULT_CLIENT_CONFIG_FILENAME);
         File cfgFile = new File(clientConfigFile);
@@ -72,6 +88,26 @@ public class ClientAppConfig {
      */
     public static List<ClientAppConfig> getClientApps(RouterContext ctx) {
         Properties clientApps = getClientAppProps(ctx);
+        return getClientApps(clientApps);
+    }
+
+    /*
+     * Go through the properties, and return a List of ClientAppConfig structures
+     */
+    public static List<ClientAppConfig> getClientApps(File cfgFile) {
+        Properties clientApps = new Properties();
+        try {
+            DataHelper.loadProps(clientApps, cfgFile);
+        } catch (IOException ioe) {
+            return Collections.EMPTY_LIST;
+        }
+        return getClientApps(clientApps);
+    }
+
+    /*
+     * Go through the properties, and return a List of ClientAppConfig structures
+     */
+    private static List<ClientAppConfig> getClientApps(Properties clientApps) {
         List<ClientAppConfig> rv = new ArrayList(8);
         int i = 0;
         while (true) {
@@ -83,6 +119,9 @@ public class ClientAppConfig {
             String delayStr = clientApps.getProperty(PREFIX + i + ".delay");
             String onBoot = clientApps.getProperty(PREFIX + i + ".onBoot");
             String disabled = clientApps.getProperty(PREFIX + i + ".startOnLoad");
+            String classpath = clientApps.getProperty(PREFIX + i + ".classpath");
+            String stopargs = clientApps.getProperty(PREFIX + i + ".stopargs");
+            String uninstallargs = clientApps.getProperty(PREFIX + i + ".uninstallargs");
             i++;
             boolean dis = disabled != null && "false".equals(disabled);
 
@@ -94,11 +133,13 @@ public class ClientAppConfig {
             if (delayStr != null && !onStartup)
                 try { delay = 1000*Integer.parseInt(delayStr); } catch (NumberFormatException nfe) {}
 
-            rv.add(new ClientAppConfig(className, clientName, args, delay, dis));
+            rv.add(new ClientAppConfig(className, clientName, args, delay, dis,
+                   classpath, stopargs, uninstallargs));
         }
         return rv;
     }
 
+    /** classpath and stopargs not supported */
     public static void writeClientAppConfig(RouterContext ctx, List apps) {
         File cfgFile = configFile(ctx);
         FileOutputStream fos = null;
diff --git a/router/java/src/net/i2p/router/startup/LoadClientAppsJob.java b/router/java/src/net/i2p/router/startup/LoadClientAppsJob.java
index 0f86f5b61b3f02813f9bd96b863d8d211f379f54..663c56025ba556dc5836df0516c2e046fa354d77 100644
--- a/router/java/src/net/i2p/router/startup/LoadClientAppsJob.java
+++ b/router/java/src/net/i2p/router/startup/LoadClientAppsJob.java
@@ -48,16 +48,20 @@ public class LoadClientAppsJob extends JobImpl {
             }
         }
     }
-    private class DelayedRunClient extends JobImpl {
+
+    public static class DelayedRunClient extends JobImpl {
         private String _className;
         private String _clientName;
         private String _args[];
+        private Log _log;
+
         public DelayedRunClient(RouterContext enclosingContext, String className, String clientName, String args[], long delay) {
             super(enclosingContext);
             _className = className;
             _clientName = clientName;
             _args = args;
-            getTiming().setStartAfter(LoadClientAppsJob.this.getContext().clock().now() + delay);
+            _log = enclosingContext.logManager().getLog(LoadClientAppsJob.class);
+            getTiming().setStartAfter(getContext().clock().now() + delay);
         }
         public String getName() { return "Delayed client job"; }
         public void runJob() {