JAL-3328 JAL-3300 changed invokeLaters to invokeAndWaits for more visible progress
[jalview.git] / getdown / src / getdown / launcher / src / main / java / com / threerings / getdown / launcher / Getdown.java
1 //
2 // Getdown - application installer, patcher and launcher
3 // Copyright (C) 2004-2018 Getdown authors
4 // https://github.com/threerings/getdown/blob/master/LICENSE
5
6 package com.threerings.getdown.launcher;
7
8 import java.awt.BorderLayout;
9 import java.awt.Container;
10 import java.awt.Dimension;
11 import java.awt.EventQueue;
12 import java.awt.Graphics;
13 import java.awt.GraphicsEnvironment;
14 import java.awt.Image;
15 import java.awt.event.ActionEvent;
16 import java.awt.image.BufferedImage;
17 import java.io.BufferedReader;
18 import java.io.File;
19 import java.io.FileInputStream;
20 import java.io.FileNotFoundException;
21 import java.io.FileReader;
22 import java.io.IOException;
23 import java.io.InputStream;
24 import java.io.InputStreamReader;
25 import java.io.PrintStream;
26 import java.net.HttpURLConnection;
27 import java.net.MalformedURLException;
28 import java.net.URL;
29 import java.util.*;
30
31 import javax.imageio.ImageIO;
32 import javax.swing.AbstractAction;
33 import javax.swing.JButton;
34 import javax.swing.JFrame;
35 import javax.swing.JLayeredPane;
36 import javax.swing.JPanel;
37
38 import com.samskivert.swing.util.SwingUtil;
39 import com.threerings.getdown.data.*;
40 import com.threerings.getdown.data.Application.UpdateInterface.Step;
41 import com.threerings.getdown.net.Downloader;
42 import com.threerings.getdown.net.HTTPDownloader;
43 import com.threerings.getdown.tools.Patcher;
44 import com.threerings.getdown.util.*;
45
46 import static com.threerings.getdown.Log.log;
47
48 /**
49  * Manages the main control for the Getdown application updater and deployment system.
50  */
51 public abstract class Getdown extends Thread
52     implements Application.StatusDisplay, RotatingBackgrounds.ImageLoader
53 {
54     public Getdown (EnvConfig envc)
55     {
56         super("Getdown");
57         try {
58             // If the silent property exists, install without bringing up any gui. If it equals
59             // launch, start the application after installing. Otherwise, just install and exit.
60             _silent = SysProps.silent();
61             if (_silent) {
62                 _launchInSilent = SysProps.launchInSilent();
63                 _noUpdate = SysProps.noUpdate();
64             }
65             // If we're running in a headless environment and have not otherwise customized
66             // silence, operate without a UI and do launch the app.
67             if (!_silent && GraphicsEnvironment.isHeadless()) {
68                 log.info("Running in headless JVM, will attempt to operate without UI.");
69                 _silent = true;
70                 _launchInSilent = true;
71             }
72             _delay = SysProps.startDelay();
73         } catch (SecurityException se) {
74             // don't freak out, just assume non-silent and no delay; we're probably already
75             // recovering from a security failure
76         }
77         try {
78             _msgs = ResourceBundle.getBundle("com.threerings.getdown.messages");
79         } catch (Exception e) {
80             // welcome to hell, where java can't cope with a classpath that contains jars that live
81             // in a directory that contains a !, at least the same bug happens on all platforms
82             String dir = envc.appDir.toString();
83             if (dir.equals(".")) {
84                 dir = System.getProperty("user.dir");
85             }
86             String errmsg = "The directory in which this application is installed:\n" + dir +
87                 "\nis invalid (" + e.getMessage() + "). If the full path to the app directory " +
88                 "contains the '!' character, this will trigger this error.";
89             fail(errmsg);
90         }
91         _app = new Application(envc);
92         _startup = System.currentTimeMillis();
93     }
94
95     /**
96      * Returns true if there are pending new resources, waiting to be installed.
97      */
98     public boolean isUpdateAvailable ()
99     {
100         return _readyToInstall && !_toInstallResources.isEmpty();
101     }
102
103     /**
104      * Installs the currently pending new resources.
105      */
106     public void install () throws IOException
107     {
108         if (SysProps.noInstall()) {
109             log.info("Skipping install due to 'no_install' sysprop.");
110         } else if (_readyToInstall) {
111             log.info("Installing " + _toInstallResources.size() + " downloaded resources:");
112             for (Resource resource : _toInstallResources) {
113                 resource.install(true);
114             }
115             _toInstallResources.clear();
116             _readyToInstall = false;
117             log.info("Install completed.");
118         } else {
119             log.info("Nothing to install.");
120         }
121     }
122
123     @Override
124     public void run ()
125     {
126         // if we have no messages, just bail because we're hosed; the error message will be
127         // displayed to the user already
128         if (_msgs == null) {
129             return;
130         }
131
132         log.info("Getdown starting", "version", Build.version(), "built", Build.time());
133
134         // determine whether or not we can write to our install directory
135         File instdir = _app.getLocalPath("");
136         if (!instdir.canWrite()) {
137             String path = instdir.getPath();
138             if (path.equals(".")) {
139                 path = System.getProperty("user.dir");
140             }
141             fail(MessageUtil.tcompose("m.readonly_error", path));
142             return;
143         }
144
145         try {
146             _dead = false;
147             // if we fail to detect a proxy, but we're allowed to run offline, then go ahead and
148             // run the app anyway because we're prepared to cope with not being able to update
149             if (detectProxy() || _app.allowOffline()) {
150                 getdown();
151             } else if (_silent) {
152                 log.warning("Need a proxy, but we don't want to bother anyone.  Exiting.");
153             } else {
154                 // create a panel they can use to configure the proxy settings
155                 _container = createContainer();
156                 // allow them to close the window to abort the proxy configuration
157                 _dead = true;
158                 configureContainer();
159                 ProxyPanel panel = new ProxyPanel(this, _msgs);
160                 // set up any existing configured proxy
161                 String[] hostPort = ProxyUtil.loadProxy(_app);
162                 panel.setProxy(hostPort[0], hostPort[1]);
163                 _container.add(panel, BorderLayout.CENTER);
164                 showContainer();
165             }
166
167         } catch (Exception e) {
168             log.warning("run() failed.", e);
169             String msg = e.getMessage();
170             if (msg == null) {
171                 msg = MessageUtil.compose("m.unknown_error", _ifc.installError);
172             } else if (!msg.startsWith("m.")) {
173                 // try to do something sensible based on the type of error
174                 if (e instanceof FileNotFoundException) {
175                     msg = MessageUtil.compose(
176                         "m.missing_resource", MessageUtil.taint(msg), _ifc.installError);
177                 } else {
178                     msg = MessageUtil.compose(
179                         "m.init_error", MessageUtil.taint(msg), _ifc.installError);
180                 }
181             }
182             fail(msg);
183         }
184     }
185
186     /**
187      * Configures our proxy settings (called by {@link ProxyPanel}) and fires up the launcher.
188      */
189     public void configProxy (String host, String port, String username, String password)
190     {
191         log.info("User configured proxy", "host", host, "port", port);
192
193         if (!StringUtil.isBlank(host)) {
194             ProxyUtil.configProxy(_app, host, port, username, password);
195         }
196
197         // clear out our UI
198         disposeContainer();
199         _container = null;
200
201         // fire up a new thread
202         new Thread(this).start();
203     }
204
205     protected boolean detectProxy () {
206         if (ProxyUtil.autoDetectProxy(_app)) {
207             return true;
208         }
209
210         // otherwise see if we actually need a proxy; first we have to initialize our application
211         // to get some sort of interface configuration and the appbase URL
212         log.info("Checking whether we need to use a proxy...");
213         try {
214             readConfig(true);
215         } catch (IOException ioe) {
216             // no worries
217         }
218         updateStatus("m.detecting_proxy");
219         if (!ProxyUtil.canLoadWithoutProxy(_app.getConfigResource().getRemote())) {
220             return false;
221         }
222
223         // we got through, so we appear not to require a proxy; make a blank proxy config so that
224         // we don't go through this whole detection process again next time
225         log.info("No proxy appears to be needed.");
226         ProxyUtil.saveProxy(_app, null, null);
227         return true;
228     }
229
230     protected void readConfig (boolean preloads) throws IOException {
231         Config config = _app.init(true);
232         if (preloads) doPredownloads(_app.getResources());
233         _ifc = new Application.UpdateInterface(config);
234         _status.setAppbase(_app.getAppbase());
235     }
236
237     /**
238      * Downloads and installs (without verifying) any resources that are marked with a
239      * {@code PRELOAD} attribute.
240      * @param resources the full set of resources from the application (the predownloads will be
241      * extracted from it).
242      */
243     protected void doPredownloads (Collection<Resource> resources) {
244         List<Resource> predownloads = new ArrayList<>();
245         for (Resource rsrc : resources) {
246             if (rsrc.shouldPredownload() && !rsrc.getLocal().exists()) {
247                 predownloads.add(rsrc);
248             }
249         }
250
251         try {
252             download(predownloads);
253             for (Resource rsrc : predownloads) {
254                 rsrc.install(false); // install but don't validate yet
255             }
256         } catch (IOException ioe) {
257             log.warning("Failed to predownload resources. Continuing...", ioe);
258         }
259     }
260
261     /**
262      * Does the actual application validation, update and launching business.
263      */
264     protected void getdown ()
265     {
266         try {
267             // first parses our application deployment file
268             try {
269                 readConfig(true);
270             } catch (IOException ioe) {
271                 log.warning("Failed to initialize: " + ioe);
272                 _app.attemptRecovery(this);
273                 // and re-initalize
274                 readConfig(true);
275                 // and force our UI to be recreated with the updated info
276                 createInterfaceAsync(true);
277             }
278             if (!_noUpdate && !_app.lockForUpdates()) {
279                 throw new MultipleGetdownRunning();
280             }
281
282             // Update the config modtime so a sleeping getdown will notice the change.
283             File config = _app.getLocalPath(Application.CONFIG_FILE);
284             if (!config.setLastModified(System.currentTimeMillis())) {
285                 log.warning("Unable to set modtime on config file, will be unable to check for " +
286                             "another instance of getdown running while this one waits.");
287             }
288             if (_delay > 0) {
289                 // don't hold the lock while waiting, let another getdown proceed if it starts.
290                 _app.releaseLock();
291                 // Store the config modtime before waiting the delay amount of time
292                 long lastConfigModtime = config.lastModified();
293                 log.info("Waiting " + _delay + " minutes before beginning actual work.");
294                 Thread.sleep(_delay * 60 * 1000);
295                 if (lastConfigModtime < config.lastModified()) {
296                     log.warning("getdown.txt was modified while getdown was waiting.");
297                     throw new MultipleGetdownRunning();
298                 }
299             }
300
301             // if no_update was specified, directly start the app without updating
302             if (_noUpdate) {
303                 log.info("Launching without update!");
304                 launch();
305                 return;
306             }
307
308             // we create this tracking counter here so that we properly note the first time through
309             // the update process whether we previously had validated resources (which means this
310             // is not a first time install); we may, in the course of updating, wipe out our
311             // validation markers and revalidate which would make us think we were doing a fresh
312             // install if we didn't specifically remember that we had validated resources the first
313             // time through
314             int[] alreadyValid = new int[1];
315
316             // we'll keep track of all the resources we unpack
317             Set<Resource> unpacked = new HashSet<>();
318
319             _toInstallResources = new HashSet<>();
320             _readyToInstall = false;
321
322             // setStep(Step.START);
323             for (int ii = 0; ii < MAX_LOOPS; ii++) {
324                 // make sure we have the desired version and that the metadata files are valid...
325                 setStep(Step.VERIFY_METADATA);
326                 setStatusAsync("m.validating", -1, -1L, false);
327                 if (_app.verifyMetadata(this)) {
328                     log.info("Application requires update.");
329                     update();
330                     // loop back again and reverify the metadata
331                     continue;
332                 }
333
334                 // now verify (and download) our resources...
335                 setStep(Step.VERIFY_RESOURCES);
336                 setStatusAsync("m.validating", -1, -1L, false);
337                 Set<Resource> toDownload = new HashSet<>();
338                 _app.verifyResources(_progobs, alreadyValid, unpacked,
339                                      _toInstallResources, toDownload);
340
341                 if (toDownload.size() > 0) {
342                     // we have resources to download, also note them as to-be-installed
343                     for (Resource r : toDownload) {
344                         if (!_toInstallResources.contains(r)) {
345                             _toInstallResources.add(r);
346                         }
347                     }
348
349                     try {
350                         // if any of our resources have already been marked valid this is not a
351                         // first time install and we don't want to enable tracking
352                         _enableTracking = (alreadyValid[0] == 0);
353                         reportTrackingEvent("app_start", -1);
354
355                         // redownload any that are corrupt or invalid...
356                         log.info(toDownload.size() + " of " + _app.getAllActiveResources().size() +
357                                  " rsrcs require update (" + alreadyValid[0] + " assumed valid).");
358                         setStep(Step.REDOWNLOAD_RESOURCES);
359                         download(toDownload);
360
361                         reportTrackingEvent("app_complete", -1);
362
363                     } finally {
364                         _enableTracking = false;
365                     }
366
367                     // now we'll loop back and try it all again
368                     continue;
369                 }
370
371                 // if we aren't running in a JVM that meets our version requirements, either
372                 // complain or attempt to download and install the appropriate version
373                 if (!_app.haveValidJavaVersion()) {
374                     // download and install the necessary version of java, then loop back again and
375                     // reverify everything; if we can't download java; we'll throw an exception
376                     log.info("Attempting to update Java VM...");
377                     setStep(Step.UPDATE_JAVA);
378                     _enableTracking = true; // always track JVM downloads
379                     try {
380                         updateJava();
381                     } finally {
382                         _enableTracking = false;
383                     }
384                     continue;
385                 }
386
387                 // if we were downloaded in full from another service (say, Steam), we may
388                 // not have unpacked all of our resources yet
389                 if (Boolean.getBoolean("check_unpacked")) {
390                     File ufile = _app.getLocalPath("unpacked.dat");
391                     long version = -1;
392                     long aversion = _app.getVersion();
393                     if (!ufile.exists()) {
394                         ufile.createNewFile();
395                     } else {
396                         version = VersionUtil.readVersion(ufile);
397                     }
398
399                     if (version < aversion) {
400                         log.info("Performing unpack", "version", version, "aversion", aversion);
401                         setStep(Step.UNPACK);
402                         updateStatus("m.validating");
403                         _app.unpackResources(_progobs, unpacked);
404                         try {
405                             VersionUtil.writeVersion(ufile, aversion);
406                         } catch (IOException ioe) {
407                             log.warning("Failed to update unpacked version", ioe);
408                         }
409                     }
410                 }
411
412                 // assuming we're not doing anything funny, install the update
413                 _readyToInstall = true;
414                 install();
415
416                 // Only launch if we aren't in silent mode. Some mystery program starting out
417                 // of the blue would be disconcerting.
418                 if (!_silent || _launchInSilent) {
419                     // And another final check for the lock. It'll already be held unless
420                     // we're in silent mode.
421                     _app.lockForUpdates();
422                     launch();
423                 }
424                 return;
425             }
426
427             log.warning("Pants! We couldn't get the job done.");
428             throw new IOException("m.unable_to_repair");
429
430         } catch (Exception e) {
431             log.warning("getdown() failed.", e);
432             String msg = e.getMessage();
433             if (msg == null) {
434                 msg = MessageUtil.compose("m.unknown_error", _ifc.installError);
435             } else if (!msg.startsWith("m.")) {
436                 // try to do something sensible based on the type of error
437                 if (e instanceof FileNotFoundException) {
438                     msg = MessageUtil.compose(
439                         "m.missing_resource", MessageUtil.taint(msg), _ifc.installError);
440                 } else {
441                     msg = MessageUtil.compose(
442                         "m.init_error", MessageUtil.taint(msg), _ifc.installError);
443                 }
444             }
445             // Since we're dead, clear off the 'time remaining' label along with displaying the
446             // error message
447             fail(msg);
448             _app.releaseLock();
449         }
450     }
451
452     // documentation inherited from interface
453     @Override
454     public void updateStatus (String message)
455     {
456         setStatusAsync(message, -1, -1L, true);
457     }
458
459     /**
460      * Load the image at the path. Before trying the exact path/file specified we will look to see
461      * if we can find a localized version by sticking a {@code _<language>} in front of the "." in
462      * the filename.
463      */
464     @Override
465     public BufferedImage loadImage (String path)
466     {
467         if (StringUtil.isBlank(path)) {
468             return null;
469         }
470
471         File imgpath = null;
472         try {
473             // First try for a localized image.
474             String localeStr = Locale.getDefault().getLanguage();
475             imgpath = _app.getLocalPath(path.replace(".", "_" + localeStr + "."));
476             return ImageIO.read(imgpath);
477         } catch (IOException ioe) {
478             // No biggie, we'll try the generic one.
479         }
480
481         // If that didn't work, try a generic one.
482         try {
483             imgpath = _app.getLocalPath(path);
484             return ImageIO.read(imgpath);
485         } catch (IOException ioe2) {
486             log.warning("Failed to load image", "path", imgpath, "error", ioe2);
487             return null;
488         }
489     }
490
491     /**
492      * Downloads and installs an Java VM bundled with the application. This is called if we are not
493      * running with the necessary Java version.
494      */
495     protected void updateJava ()
496         throws IOException
497     {
498         Resource vmjar = _app.getJavaVMResource();
499         if (vmjar == null) {
500             throw new IOException("m.java_download_failed");
501         }
502
503         // on Windows, if the local JVM is in use, we will not be able to replace it with an
504         // updated JVM; we detect this by attempting to rename the java.dll to its same name, which
505         // will fail on Windows for in use files; hackery!
506         File javaLocalDir = new File(_app.getAppDir(), LaunchUtil.LOCAL_JAVA_DIR+File.separator);
507         File javaDll = new File(javaLocalDir, "bin" + File.separator + "java.dll");
508         if (javaDll.exists()) {
509             if (!javaDll.renameTo(javaDll)) {
510                 log.info("Cannot update local Java VM as it is in use.");
511                 return;
512             }
513             log.info("Can update local Java VM as it is not in use.");
514         }
515
516         reportTrackingEvent("jvm_start", -1);
517
518         updateStatus("m.downloading_java");
519         List<Resource> list = new ArrayList<>();
520         list.add(vmjar);
521         download(list);
522
523         reportTrackingEvent("jvm_unpack", -1);
524
525         updateStatus("m.unpacking_java");
526         vmjar.install(true);
527
528         // these only run on non-Windows platforms, so we use Unix file separators
529         String localJavaDir = LaunchUtil.LOCAL_JAVA_DIR + "/";
530         FileUtil.makeExecutable(_app.getLocalPath(localJavaDir + "bin/java"));
531         FileUtil.makeExecutable(_app.getLocalPath(localJavaDir + "lib/jspawnhelper"));
532         FileUtil.makeExecutable(_app.getLocalPath(localJavaDir + "lib/amd64/jspawnhelper"));
533
534         // lastly regenerate the .jsa dump file that helps Java to start up faster
535         String vmpath = LaunchUtil.getJVMPath(_app.getLocalPath(""));
536         try {
537             log.info("Regenerating classes.jsa for " + vmpath + "...");
538             Runtime.getRuntime().exec(vmpath + " -Xshare:dump");
539         } catch (Exception e) {
540             log.warning("Failed to regenerate .jsa dump file", "error", e);
541         }
542
543         reportTrackingEvent("jvm_complete", -1);
544     }
545
546     /**
547      * Called if the application is determined to be of an old version.
548      */
549     protected void update ()
550         throws IOException
551     {
552         // first clear all validation markers
553         _app.clearValidationMarkers();
554
555         // attempt to download the patch files
556         Resource patch = _app.getPatchResource(null);
557         if (patch != null) {
558             List<Resource> list = new ArrayList<>();
559             list.add(patch);
560
561             // add the auxiliary group patch files for activated groups
562             for (Application.AuxGroup aux : _app.getAuxGroups()) {
563                 if (_app.isAuxGroupActive(aux.name)) {
564                     patch = _app.getPatchResource(aux.name);
565                     if (patch != null) {
566                         list.add(patch);
567                     }
568                 }
569             }
570
571             // show the patch notes button, if applicable
572             if (!StringUtil.isBlank(_ifc.patchNotesUrl)) {
573                 createInterfaceAsync(false);
574                 EventQueue.invokeLater(new Runnable() {
575                     public void run () {
576                         _patchNotes.setVisible(true);
577                     }
578                 });
579             }
580
581             // download the patch files...
582             setStep(Step.DOWNLOAD);
583             download(list);
584
585             // and apply them...
586             setStep(Step.PATCH);
587             updateStatus("m.patching");
588
589             long[] sizes = new long[list.size()];
590             Arrays.fill(sizes, 1L);
591             ProgressAggregator pragg = new ProgressAggregator(_progobs, sizes);
592             int ii = 0; for (Resource prsrc : list) {
593                 ProgressObserver pobs = pragg.startElement(ii++);
594                 try {
595                     // install the patch file (renaming them from _new)
596                     prsrc.install(false);
597                     // now apply the patch
598                     Patcher patcher = new Patcher();
599                     patcher.patch(prsrc.getLocal().getParentFile(), prsrc.getLocal(), pobs);
600                 } catch (Exception e) {
601                     log.warning("Failed to apply patch", "prsrc", prsrc, e);
602                 }
603
604                 // clean up the patch file
605                 if (!FileUtil.deleteHarder(prsrc.getLocal())) {
606                     log.warning("Failed to delete '" + prsrc + "'.");
607                 }
608             }
609         }
610
611         // if the patch resource is null, that means something was booched in the application, so
612         // we skip the patching process but update the metadata which will result in a "brute
613         // force" upgrade
614
615         // finally update our metadata files...
616         _app.updateMetadata();
617         // ...and reinitialize the application
618         readConfig(false);
619     }
620
621     /**
622      * Called if the application is determined to require resource downloads.
623      */
624     protected void download (Collection<Resource> resources)
625         throws IOException
626     {
627         // create our user interface
628         createInterfaceAsync(false);
629
630         Downloader dl = new HTTPDownloader(_app.proxy) {
631             @Override protected void resolvingDownloads () {
632                 updateStatus("m.resolving");
633             }
634
635             @Override protected void downloadProgress (int percent, long remaining) {
636                 // check for another getdown running at 0 and every 10% after that
637                 if (_lastCheck == -1 || percent >= _lastCheck + 10) {
638                     if (_delay > 0) {
639                         // stop the presses if something else is holding the lock
640                         boolean locked = _app.lockForUpdates();
641                         _app.releaseLock();
642                         if (locked) abort();
643                     }
644                     _lastCheck = percent;
645                 }
646                 setStatusAsync("m.downloading", stepToGlobalPercent(percent), remaining, true);
647                 if (percent > 0) {
648                     reportTrackingEvent("progress", percent);
649                 }
650             }
651
652             @Override protected void downloadFailed (Resource rsrc, Exception e) {
653                 updateStatus(MessageUtil.tcompose("m.failure", e.getMessage()));
654                 log.warning("Download failed", "rsrc", rsrc, e);
655             }
656
657             /** The last percentage at which we checked for another getdown running, or -1 for not
658              * having checked at all. */
659             protected int _lastCheck = -1;
660         };
661         if (!dl.download(resources, _app.maxConcurrentDownloads())) {
662             // if we aborted due to detecting another getdown running, we want to report here
663             throw new MultipleGetdownRunning();
664         }
665     }
666
667     /**
668      * Called to launch the application if everything is determined to be ready to go.
669      */
670     protected void launch ()
671     {
672         setStep(Step.LAUNCH);
673         setStatusAsync("m.launching", stepToGlobalPercent(100), -1L, false);
674
675         try {
676             if (invokeDirect()) {
677                 // we want to close the Getdown window, as the app is launching
678                 disposeContainer();
679                 _app.releaseLock();
680                 _app.invokeDirect();
681
682             } else {
683                 Process proc;
684                 if (_app.hasOptimumJvmArgs()) {
685                     // if we have "optimum" arguments, we want to try launching with them first
686                     proc = _app.createProcess(true);
687
688                     long fallback = System.currentTimeMillis() + FALLBACK_CHECK_TIME;
689                     boolean error = false;
690                     while (fallback > System.currentTimeMillis()) {
691                         try {
692                             error = proc.exitValue() != 0;
693                             break;
694                         } catch (IllegalThreadStateException e) {
695                             Thread.yield();
696                         }
697                     }
698
699                     if (error) {
700                         log.info("Failed to launch with optimum arguments; falling back.");
701                         proc = _app.createProcess(false);
702                     }
703                 } else {
704                     proc = _app.createProcess(false);
705                 }
706                 
707                 // close standard in to avoid choking standard out of the launched process
708                 proc.getInputStream().close();
709                 // close standard out, since we're not going to write to anything to it anyway
710                 proc.getOutputStream().close();
711
712                 // on Windows 98 and ME we need to stick around and read the output of stderr lest
713                 // the process fill its output buffer and choke, yay!
714                 final InputStream stderr = proc.getErrorStream();
715                 if (LaunchUtil.mustMonitorChildren()) {
716                     // close our window if it's around
717                     disposeContainer();
718                     _container = null;
719                     copyStream(stderr, System.err);
720                     log.info("Process exited: " + proc.waitFor());
721
722                 } else {
723                     // spawn a daemon thread that will catch the early bits of stderr in case the
724                     // launch fails
725                     Thread t = new Thread() {
726                         @Override public void run () {
727                             copyStream(stderr, System.err);
728                         }
729                     };
730                     t.setDaemon(true);
731                     t.start();
732                 }
733             }
734
735             _container.setVisible(true);
736             _container.validate();
737
738             // if we have a UI open and we haven't been around for at least 5 seconds (the default
739             // for min_show_seconds), don't stick a fork in ourselves straight away but give our
740             // lovely user a chance to see what we're doing
741             long uptime = System.currentTimeMillis() - _startup;
742             long minshow = _ifc.minShowSeconds * 1000L;
743             if (_container != null && uptime < minshow) {
744                 try {
745                     Thread.sleep(minshow - uptime);
746                 } catch (Exception e) {
747                 }
748             }
749
750             // pump the percent up to 100%
751             setStatusAsync(null, 100, -1L, false);
752             exit(0);
753
754         } catch (Exception e) {
755             log.warning("launch() failed.", e);
756         }
757     }
758
759     /**
760      * Creates our user interface, which we avoid doing unless we actually have to update
761      * something. NOTE: this happens on the next UI tick, not immediately.
762      *
763      * @param reinit - if the interface should be reinitialized if it already exists.
764      */
765     protected void createInterfaceAsync (final boolean reinit)
766     {
767         if (_silent || (_container != null && !reinit)) {
768             return;
769         }
770 /*
771         EventQueue.invokeLater(new Runnable() {
772             public void run () {
773 */
774         try {
775         EventQueue.invokeAndWait(new Runnable() {
776             public void run () {
777                 
778                 if (_container == null || reinit) {
779                     if (_container == null) {
780                         _container = createContainer();
781                     } else {
782                         _container.removeAll();
783                     }
784                     configureContainer();
785                     _layers = new JLayeredPane();
786                     
787                     
788                     
789                     // added in the instant display of a splashscreen
790                     try {
791                       readConfig(false);
792                       Graphics g = _container.getGraphics();
793                       BufferedImage iBgImage = loadImage(_ifc.instantBackgroundImage);
794                       boolean ibg = true;
795                       if (iBgImage == null) {
796                           iBgImage = loadImage(_ifc.backgroundImage);
797                           ibg = false;
798                       }
799                       if (iBgImage != null) {
800                                                   final BufferedImage bgImage = iBgImage;
801                                                   int bwidth = bgImage.getWidth();
802                                                   int bheight = bgImage.getHeight();
803                                                   
804                                                   log.info("Displaying instant background image", ibg?"instant_background_image":"background_image");
805
806                                                   instantSplashPane = new JPanel() {
807                                                         @Override
808                                                         protected void paintComponent(Graphics g)
809                                                         {
810                                                           super.paintComponent(g);
811                                                           // attempt to draw a background image...
812                                                           if (bgImage != null) {
813                                                                 g.drawImage(bgImage, 0, 0, this);
814                                                           }
815                                                         }
816                                                   };
817
818                                                   instantSplashPane.setSize(bwidth,bheight);
819                                                   instantSplashPane.setPreferredSize(new Dimension(bwidth,bheight));
820
821                                                   _layers.add(instantSplashPane, Integer.valueOf(0));
822                                                 
823                                                   _container.setPreferredSize(new Dimension(bwidth,bheight));
824                       }
825                     } catch (Exception e) {
826                       log.warning("Failed to set instant background image", "ibg", _ifc.instantBackgroundImage);
827                     }
828                     
829                     _container.add(_layers, BorderLayout.CENTER);
830                     _patchNotes = new JButton(new AbstractAction(_msgs.getString("m.patch_notes")) {
831                         @Override public void actionPerformed (ActionEvent event) {
832                             showDocument(_ifc.patchNotesUrl);
833                         }
834                     });
835                     _patchNotes.setFont(StatusPanel.FONT);
836                     _layers.add(_patchNotes);
837                     _status = new StatusPanel(_msgs);
838                                         //setStatusAsync("test", stepToGlobalPercent(1), -1L, false);
839                     _layers.add(_status, Integer.valueOf(10));
840                     initInterface();
841                 }
842                 showContainer();
843             }
844         });
845         } catch (Exception e) {
846                 e.printStackTrace();
847         }
848     }
849
850     /**
851      * Initializes the interface with the current UpdateInterface and backgrounds.
852      */
853     protected void initInterface ()
854     {
855         RotatingBackgrounds newBackgrounds = getBackground();
856         if (_background == null || newBackgrounds.getNumImages() > 0) {
857             // Leave the old _background in place if there is an old one to leave in place
858             // and the new getdown.txt didn't yield any images.
859             _background = newBackgrounds;
860         }
861         _status.init(_ifc, _background, getProgressImage());
862         Dimension size = _status.getPreferredSize();
863         _status.setSize(size);
864         //_status.updateStatusLabel();
865         _layers.setPreferredSize(size);
866
867         _patchNotes.setBounds(_ifc.patchNotes.x, _ifc.patchNotes.y,
868                               _ifc.patchNotes.width, _ifc.patchNotes.height);
869         _patchNotes.setVisible(false);
870
871         // we were displaying progress while the UI wasn't up. Now that it is, whatever progress
872         // is left is scaled into a 0-100 DISPLAYED progress.
873         _uiDisplayPercent = _lastGlobalPercent;
874         _stepMinPercent = _lastGlobalPercent = 0;
875     }
876
877     protected RotatingBackgrounds getBackground ()
878     {
879         if (_ifc.rotatingBackgrounds != null && _ifc.rotatingBackgrounds.size() > 0) {
880             if (_ifc.backgroundImage != null) {
881                 log.warning("ui.background_image and ui.rotating_background were both specified. " +
882                             "The background image is being used.");
883             }
884             return new RotatingBackgrounds(_ifc.rotatingBackgrounds, _ifc.errorBackground, Getdown.this);
885         } else if (_ifc.backgroundImage != null) {
886             return new RotatingBackgrounds(loadImage(_ifc.backgroundImage));
887         } else {
888             return new RotatingBackgrounds();
889         }
890     }
891
892     protected Image getProgressImage ()
893     {
894         return loadImage(_ifc.progressImage);
895     }
896
897     protected void handleWindowClose ()
898     {
899         if (_dead) {
900             exit(0);
901         } else {
902             if (_abort == null) {
903                 _abort = new AbortPanel(Getdown.this, _msgs);
904             }
905             _abort.pack();
906             SwingUtil.centerWindow(_abort);
907             _abort.setVisible(true);
908             _abort.setState(JFrame.NORMAL);
909             _abort.requestFocus();
910         }
911     }
912
913     /**
914      * Update the status to indicate getdown has failed for the reason in <code>message</code>.
915      */
916     protected void fail (String message)
917     {
918         _dead = true;
919         setStatusAsync(message, stepToGlobalPercent(0), -1L, true);
920     }
921
922     /**
923      * Set the current step, which will be used to globalize per-step percentages.
924      */
925     protected void setStep (Step step)
926     {
927         int finalPercent = -1;
928         for (Integer perc : _ifc.stepPercentages.get(step)) {
929             if (perc > _stepMaxPercent) {
930                 finalPercent = perc;
931                 break;
932             }
933         }
934         if (finalPercent == -1) {
935             // we've gone backwards and this step will be ignored
936             return;
937         }
938
939         _stepMaxPercent = finalPercent;
940         _stepMinPercent = _lastGlobalPercent;
941     }
942
943     /**
944      * Convert a step percentage to the global percentage.
945      */
946     protected int stepToGlobalPercent (int percent)
947     {
948         int adjustedMaxPercent =
949             ((_stepMaxPercent - _uiDisplayPercent) * 100) / (100 - _uiDisplayPercent);
950         _lastGlobalPercent = Math.max(_lastGlobalPercent,
951             _stepMinPercent + (percent * (adjustedMaxPercent - _stepMinPercent)) / 100);
952         return _lastGlobalPercent;
953     }
954
955     /**
956      * Updates the status. NOTE: this happens on the next UI tick, not immediately.
957      */
958     protected void setStatusAsync (final String message, final int percent, final long remaining,
959                                    boolean createUI)
960     {
961         if (_status == null && createUI) {
962             createInterfaceAsync(false);
963         }
964
965 /*
966         EventQueue.invokeLater(new Runnable() {
967             public void run () {
968 */
969         try {
970         EventQueue.invokeAndWait(new Runnable() {
971             public void run () {
972
973                 if (_status == null) {
974                     if (message != null) {
975                         log.info("Dropping status '" + message + "'.");
976                     }
977                     return;
978                 }
979                 if (message != null) {
980                     _status.setStatus(message, _dead);
981                 }
982                 if (_dead) {
983                     _status.setProgress(0, -1L);
984                 } else if (percent >= 0) {
985                     _status.setProgress(percent, remaining);
986                 }
987             }
988         });
989         } catch (Exception e) {
990                 e.printStackTrace();
991         }
992     }
993
994     protected void reportTrackingEvent (String event, int progress)
995     {
996         if (!_enableTracking) {
997             return;
998
999         } else if (progress > 0) {
1000             // we need to make sure we do the right thing if we skip over progress levels
1001             do {
1002                 URL url = _app.getTrackingProgressURL(++_reportedProgress);
1003                 if (url != null) {
1004                     new ProgressReporter(url).start();
1005                 }
1006             } while (_reportedProgress <= progress);
1007
1008         } else {
1009             URL url = _app.getTrackingURL(event);
1010             if (url != null) {
1011                 new ProgressReporter(url).start();
1012             }
1013         }
1014     }
1015
1016     /**
1017      * Creates the container in which our user interface will be displayed.
1018      */
1019     protected abstract Container createContainer ();
1020
1021     /**
1022      * Configures the interface container based on the latest UI config.
1023      */
1024     protected abstract void configureContainer ();
1025
1026     /**
1027      * Shows the container in which our user interface will be displayed.
1028      */
1029     protected abstract void showContainer ();
1030
1031     /**
1032      * Disposes the container in which we have our user interface.
1033      */
1034     protected abstract void disposeContainer ();
1035
1036     /**
1037      * If this method returns true we will run the application in the same JVM, otherwise we will
1038      * fork off a new JVM. Some options are not supported if we do not fork off a new JVM.
1039      */
1040     protected boolean invokeDirect ()
1041     {
1042         return SysProps.direct();
1043     }
1044
1045     /**
1046      * Requests to show the document at the specified URL in a new window.
1047      */
1048     protected abstract void showDocument (String url);
1049
1050     /**
1051      * Requests that Getdown exit.
1052      */
1053     protected abstract void exit (int exitCode);
1054
1055     /**
1056      * Copies the supplied stream from the specified input to the specified output. Used to copy
1057      * our child processes stderr and stdout to our own stderr and stdout.
1058      */
1059     protected static void copyStream (InputStream in, PrintStream out)
1060     {
1061         try {
1062             BufferedReader reader = new BufferedReader(new InputStreamReader(in));
1063             String line;
1064             while ((line = reader.readLine()) != null) {
1065                 out.print(line);
1066                 out.flush();
1067             }
1068         } catch (IOException ioe) {
1069             log.warning("Failure copying", "in", in, "out", out, "error", ioe);
1070         }
1071     }
1072
1073     /** Used to fetch a progress report URL. */
1074     protected class ProgressReporter extends Thread
1075     {
1076         public ProgressReporter (URL url) {
1077             setDaemon(true);
1078             _url = url;
1079         }
1080
1081         @Override
1082         public void run () {
1083             try {
1084                 HttpURLConnection ucon = ConnectionUtil.openHttp(_app.proxy, _url, 0, 0);
1085
1086                 // if we have a tracking cookie configured, configure the request with it
1087                 if (_app.getTrackingCookieName() != null &&
1088                     _app.getTrackingCookieProperty() != null) {
1089                     String val = System.getProperty(_app.getTrackingCookieProperty());
1090                     if (val != null) {
1091                         ucon.setRequestProperty("Cookie", _app.getTrackingCookieName() + "=" + val);
1092                     }
1093                 }
1094
1095                 // now request our tracking URL and ensure that we get a non-error response
1096                 ucon.connect();
1097                 try {
1098                     if (ucon.getResponseCode() != HttpURLConnection.HTTP_OK) {
1099                         log.warning("Failed to report tracking event",
1100                             "url", _url, "rcode", ucon.getResponseCode());
1101                     }
1102                 } finally {
1103                     ucon.disconnect();
1104                 }
1105
1106             } catch (IOException ioe) {
1107                 log.warning("Failed to report tracking event", "url", _url, "error", ioe);
1108             }
1109         }
1110
1111         protected URL _url;
1112     }
1113
1114     /** Used to pass progress on to our user interface. */
1115     protected ProgressObserver _progobs = new ProgressObserver() {
1116         public void progress (int percent) {
1117             setStatusAsync(null, stepToGlobalPercent(percent), -1L, false);
1118         }
1119     };
1120
1121     protected Application _app;
1122     protected Application.UpdateInterface _ifc = new Application.UpdateInterface(Config.EMPTY);
1123
1124     protected ResourceBundle _msgs;
1125     protected Container _container;
1126     protected JLayeredPane _layers;
1127     protected JPanel instantSplashPane;
1128     protected StatusPanel _status;
1129     protected JButton _patchNotes;
1130     protected AbortPanel _abort;
1131     protected RotatingBackgrounds _background;
1132
1133     protected boolean _dead;
1134     protected boolean _silent;
1135     protected boolean _launchInSilent;
1136     protected boolean _noUpdate;
1137     protected long _startup;
1138
1139     protected Set<Resource> _toInstallResources;
1140     protected boolean _readyToInstall;
1141
1142     protected boolean _enableTracking = true;
1143     protected int _reportedProgress = 0;
1144
1145     /** Number of minutes to wait after startup before beginning any real heavy lifting. */
1146     protected int _delay;
1147
1148     protected int _stepMaxPercent;
1149     protected int _stepMinPercent;
1150     protected int _lastGlobalPercent;
1151     protected int _uiDisplayPercent;
1152
1153     protected static final int MAX_LOOPS = 5;
1154     protected static final long FALLBACK_CHECK_TIME = 1000L;
1155     
1156 }