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