View Javadoc
1   package fr.ifremer.quadrige3.ui.updater;
2   
3   /*-
4    * #%L
5    * Quadrige3 Core :: UI Updater
6    * %%
7    * Copyright (C) 2017 - 2018 Ifremer
8    * %%
9    * This program is free software: you can redistribute it and/or modify
10   * it under the terms of the GNU Affero General Public License as published by
11   * the Free Software Foundation, either version 3 of the License, or
12   * (at your option) any later version.
13   *
14   * This program is distributed in the hope that it will be useful,
15   * but WITHOUT ANY WARRANTY; without even the implied warranty of
16   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17   * GNU General Public License for more details.
18   *
19   * You should have received a copy of the GNU Affero General Public License
20   * along with this program.  If not, see <http://www.gnu.org/licenses/>.
21   * #L%
22   */
23  
24  import javax.swing.JOptionPane;
25  import java.io.*;
26  import java.net.URL;
27  import java.net.URLConnection;
28  import java.nio.charset.Charset;
29  import java.nio.charset.StandardCharsets;
30  import java.nio.file.*;
31  import java.nio.file.attribute.BasicFileAttributes;
32  import java.nio.file.attribute.PosixFilePermission;
33  import java.text.DateFormat;
34  import java.text.MessageFormat;
35  import java.util.*;
36  import java.util.ResourceBundle.Control;
37  import java.util.stream.Collectors;
38  
39  import static java.nio.file.FileVisitResult.CONTINUE;
40  import static java.nio.file.FileVisitResult.SKIP_SUBTREE;
41  import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
42  
43  /**
44   * Common Updater class
45   *
46   * @author Ludovic Pecquot <ludovic.pecquot@e-is.pro>
47   */
48  public class Updater {
49  
50      private static final String NEW_DIR = "NEW";
51      private static final String OLD_DIR = "OLD";
52      private static final String JRE_DIR = "jre";
53      private static final String LAUNCHER_DIR = "launcher";
54      private static final String UPDATE_RUNTIME_FILENAME = "update_runtime";
55      private static final String UPDATE_RUNTIME_WINDOWS_CMD = "cmd /c start \"" + UPDATE_RUNTIME_FILENAME + "\" \"%s\"";
56      private static final String UPDATE_RUNTIME_UNIX_CMD = "./%s";
57      private static final String EXTERNAL_BACKUP_CMD = "external_backup";
58      private static final String BIN_WINDOWS_EXT = ".exe";
59      private static final String BATCH_WINDOWS_EXT = ".bat";
60      private static final String BATCH_UNIX_EXT = ".sh";
61  
62      private static final String LAUNCHER_PROPERTIES = "launcher.properties";
63      private static final String PROPERTY_NAME = "NAME";
64  
65      private static final String APP_DIR = "application";
66      private static final String CONFIG_DIR = "config";
67      private static final String CONFIG_EXT = ".config";
68      private static final String I18N_DIR = "i18n";
69      private static final String HELP_DIR = "help";
70      private static final String PLUGINS_DIR = "plugins";
71      private static final String DB_CACHE_DIR = "data/dbcache";
72      private static final String VERSION_FILE = "version.appup";
73  
74      private static final int NORMAL_EXIT_CODE = 0;
75      private static final int ERROR_EXIT_CODE = 1;
76      private static final int STOP_EXIT_CODE = 90;
77  
78      private Path basePath;
79      private Properties launcherProperties;
80  
81      private static ResourceBundle bundle;
82  
83      /**
84       * <p>main.</p>
85       *
86       * @param args an array of {@link String} objects.
87       */
88      public static void main(String[] args) {
89  
90          // Get the resource bundle
91          bundle = ResourceBundle.getBundle("i18n/updater", new UTF8Control());
92  
93          // Instantiate the launcher
94          Updater updater = new Updater();
95          int exitCode = updater.execute();
96  
97          // exit 
98          System.exit(exitCode);
99  
100     }
101 
102     /**
103      * <p>execute.</p>
104      *
105      * @return a int.
106      */
107     private int execute() {
108         try {
109 
110             // Get the current directory where executable has been launched
111             basePath = Paths.get(System.getProperty("user.dir"));
112 
113             // Read launcher properties
114             launcherProperties = readLauncherProperties();
115 
116             // Launch runtime update
117             launchRuntimeUpdate();
118 
119             // Launch update
120             launchUpdate();
121 
122         } catch (IOException ex) {
123             JOptionPane.showMessageDialog(null, ex.toString(), getTitle(), JOptionPane.ERROR_MESSAGE);
124             ex.printStackTrace(System.err);
125             return STOP_EXIT_CODE;
126         } catch (Exception ex) {
127             ex.printStackTrace(System.err);
128             return ERROR_EXIT_CODE;
129         }
130         return NORMAL_EXIT_CODE;
131     }
132 
133     private void launchUpdate() throws IOException {
134 
135         System.out.println(getString("updater.started", DateFormat.getInstance().format(new Date())));
136 
137         // Update main modules
138         boolean appUpdated = updateModule(basePath, APP_DIR);
139         updateModule(basePath, I18N_DIR);
140         updateModule(basePath, HELP_DIR);
141 
142         // Update config
143         updateConfig(basePath);
144 
145         // Update plugins
146         updatePlugins();
147 
148         // Update other necessary files
149         updateOtherFiles();
150 
151         // Cleaning process
152         cleanObsoleteFiles();
153         cleanPath(basePath.resolve(NEW_DIR));
154         cleanPath(basePath.resolve(PLUGINS_DIR).resolve(NEW_DIR));
155         // Clean OLD directory (Mantis #48682)
156         cleanPath(basePath.resolve(OLD_DIR));
157 
158         if (appUpdated) {
159             // clean db cache
160             cleanPath(basePath.resolve(DB_CACHE_DIR));
161         }
162 
163         System.out.println(getString("updater.ended", DateFormat.getInstance().format(new Date())));
164 
165     }
166 
167     private boolean updateModule(Path basePath, String moduleName) throws IOException {
168 
169         // Update a single module. moduleName corresponds to the name of the directory inside the basePath
170         Path modulePath = basePath.resolve(moduleName);
171         Path moduleNewPath = basePath.resolve(NEW_DIR).resolve(moduleName);
172 
173         if (Files.isDirectory(moduleNewPath)) {
174 
175             // Delete existing module (Mantis #48682)
176             cleanPath(modulePath);
177 
178             // Installing new module
179             String newVersion = getVersion(moduleNewPath);
180             System.out.println(getString("updater.install", moduleName, newVersion));
181             moveDirectory(moduleNewPath, modulePath);
182 
183             return true;
184         }
185 
186         return false;
187     }
188 
189     private void updatePlugins() throws IOException {
190 
191         // Update the plugins
192         Path pluginsPath = basePath.resolve(PLUGINS_DIR);
193         Path pluginsNewPath = pluginsPath.resolve(NEW_DIR);
194 
195         if (Files.isDirectory(pluginsNewPath)) {
196 
197             DirectoryStream<Path> stream = Files.newDirectoryStream(pluginsNewPath, entry -> Files.isDirectory(entry));
198 
199             List<String> pluginNames = new ArrayList<>();
200             for (Path path : stream) {
201                 pluginNames.add(path.getFileName().toString());
202             }
203 
204             // Update each plugin as a module
205             for (String pluginName : pluginNames) {
206                 updateModule(pluginsPath, pluginName);
207             }
208         }
209 
210     }
211 
212     private void updateConfig(Path basePath) throws IOException {
213 
214         String configFileName = getName().toLowerCase() + CONFIG_EXT;
215         Path configPath = basePath.resolve(CONFIG_DIR);
216         Path configFilePath = configPath.resolve(configFileName);
217         Path configNewPath = basePath.resolve(NEW_DIR).resolve(CONFIG_DIR);
218         Path configNewFilePath = configNewPath.resolve(configFileName);
219 
220         if (Files.isRegularFile(configNewFilePath)) {
221 
222             String newVersion = getVersion(configNewPath);
223             System.out.println(getString("updater.install", CONFIG_DIR, newVersion));
224 
225             if (Files.isRegularFile(configFilePath)) {
226 
227                 // Process config update
228                 Properties actualProperties = new Properties();
229                 Properties newProperties = new Properties();
230                 try (Reader actualPropertiesReader = Files.newBufferedReader(configFilePath, StandardCharsets.UTF_8);
231                      Reader newPropertiesReader = Files.newBufferedReader(configNewFilePath, StandardCharsets.UTF_8)) {
232 
233                     // Load all existing properties
234                     actualProperties.load(actualPropertiesReader);
235 
236                     // Load all new properties
237                     newProperties.load(newPropertiesReader);
238                 }
239 
240                 // Add or replace new property
241                 newProperties.forEach((key, newValue) -> actualProperties.setProperty((String) key, (String) newValue));
242 
243                 try (Writer actualPropertiesWriter = Files.newBufferedWriter(configFilePath, StandardCharsets.UTF_8)) {
244 
245                     // Then save
246                     actualProperties.store(actualPropertiesWriter, null);
247                 }
248 
249             } else {
250 
251                 // Copy it if no config file at all
252                 Files.createDirectories(configFilePath.getParent());
253                 Files.copy(configNewFilePath, configFilePath);
254             }
255 
256             // Also copy version file
257             Files.copy(configNewFilePath.resolveSibling(VERSION_FILE), configFilePath.resolveSibling(VERSION_FILE), REPLACE_EXISTING);
258         }
259     }
260 
261     private Properties readLauncherProperties() throws Exception {
262 
263         Path localLauncherFile = basePath.resolve(LAUNCHER_PROPERTIES);
264         Path applicationLauncherFile = basePath.resolve(APP_DIR).resolve(LAUNCHER_PROPERTIES);
265 
266         if (!Files.exists(localLauncherFile) && !Files.exists(applicationLauncherFile)) {
267             throw new IOException(getString("updater.fileNotExists", LAUNCHER_PROPERTIES));
268         }
269 
270         Properties p1 = new Properties();
271         if (Files.isReadable(applicationLauncherFile)) {
272             try (Reader reader = new FileReader(applicationLauncherFile.toFile())) {
273                 p1.load(reader);
274             }
275         }
276 
277         Properties p = new Properties(p1);
278         if (Files.isReadable(localLauncherFile)) {
279             try (Reader reader = new FileReader(localLauncherFile.toFile())) {
280                 p.load(reader);
281             }
282         }
283 
284         // Assert properties exists
285         assertPropertyExists(p, PROPERTY_NAME);
286 
287         return p;
288     }
289 
290     @SuppressWarnings("SameParameterValue")
291     private void assertPropertyExists(Properties properties, String propertyName) {
292         String property = properties.getProperty(propertyName);
293         if (property == null || property.trim().length() == 0)
294             throw new NullPointerException(getString("updater.propertyNotFound", propertyName, LAUNCHER_PROPERTIES));
295     }
296 
297     private String getName() {
298         return launcherProperties.getProperty(PROPERTY_NAME);
299     }
300 
301     private String getTitle() {
302         return getString("updater.title", getName());
303     }
304 
305     private String getVersion(Path path) throws IOException {
306 
307         // Return the version of a module from version.appup file
308         Path versionFile = path.resolve(VERSION_FILE);
309         if (!Files.exists(versionFile)) {
310             return "0";
311         }
312         List<String> lines = Files.readAllLines(versionFile, StandardCharsets.UTF_8);
313         if (lines.isEmpty()) {
314             return "0";
315         }
316         return lines.get(0);
317     }
318 
319     private void updateOtherFiles() throws IOException {
320 
321         // External backup script
322         Path extBackupPath = basePath.resolve(APP_DIR).resolve(EXTERNAL_BACKUP_CMD + (isWindowsOS() ? BATCH_WINDOWS_EXT : BATCH_UNIX_EXT));
323         if (Files.exists(extBackupPath)) {
324             Files.move(extBackupPath, extBackupPath.getParent().getParent().resolve(extBackupPath.getFileName()), REPLACE_EXISTING);
325             // Set Unix permissions
326             if (!isWindowsOS()) {
327                 Set<PosixFilePermission> perms = new HashSet<>();
328                 //add owners permission
329                 perms.add(PosixFilePermission.OWNER_READ);
330                 perms.add(PosixFilePermission.OWNER_WRITE);
331                 perms.add(PosixFilePermission.OWNER_EXECUTE);
332                 //add group permissions
333                 perms.add(PosixFilePermission.GROUP_READ);
334                 perms.add(PosixFilePermission.GROUP_WRITE);
335                 perms.add(PosixFilePermission.GROUP_EXECUTE);
336                 //add others permissions
337                 perms.add(PosixFilePermission.OTHERS_READ);
338                 perms.add(PosixFilePermission.OTHERS_EXECUTE);
339                 Files.setPosixFilePermissions(basePath.resolve(EXTERNAL_BACKUP_CMD + BATCH_UNIX_EXT), perms);
340             }
341         }
342 
343     }
344 
345     private void copyDirectory(Path sourcePath, Path targetPath) throws IOException {
346 
347         Files.walkFileTree(sourcePath, new TreeCopier(sourcePath, targetPath));
348     }
349 
350     private void moveDirectory(Path sourcePath, Path targetPath) throws IOException {
351 
352         // Use Copy + Delete instead of a move directory (Mantis #42313)
353         copyDirectory(sourcePath, targetPath);
354         cleanPath(sourcePath);
355     }
356 
357     private void cleanPath(Path path) throws IOException {
358 
359         cleanPath(path, null);
360     }
361 
362     private void cleanPath(Path path, String glob) throws IOException {
363 
364         if (Files.isDirectory(path)) {
365             Files.walkFileTree(path, new RecursiveDeleteFileVisitor(glob));
366         }
367     }
368 
369     private void cleanObsoleteFiles() throws IOException {
370 
371         if (isWindowsOS()) {
372 
373             // Delete obsolete batch files
374             Files.deleteIfExists(basePath.resolve(getName() + BATCH_WINDOWS_EXT));
375             Files.deleteIfExists(basePath.resolve(APP_DIR).resolve("launch" + BATCH_WINDOWS_EXT));
376 
377             // Delete non Windows files
378             cleanPath(basePath, "*" + BATCH_UNIX_EXT);
379         } else {
380 
381             // Delete obsolete script files
382             Files.deleteIfExists(basePath.resolve(APP_DIR).resolve("launch" + BATCH_UNIX_EXT));
383 
384             // Delete Windows files
385             cleanPath(basePath, "*" + BATCH_WINDOWS_EXT);
386             cleanPath(basePath, "*" + BIN_WINDOWS_EXT);
387         }
388 
389         // Delete embedded files
390         Files.deleteIfExists(basePath.resolve(APP_DIR).resolve(getName() + BIN_WINDOWS_EXT));
391         Files.deleteIfExists(basePath.resolve(APP_DIR).resolve(getName() + BATCH_UNIX_EXT));
392         cleanPath(basePath.resolve(APP_DIR).resolve("launcher"));
393 
394     }
395 
396     private void launchRuntimeUpdate() throws IOException {
397 
398         if (checkRuntimeUpdate()) {
399 
400             // Launch update runtime script
401             Runtime.getRuntime().exec(String.format(
402                     isWindowsOS() ? UPDATE_RUNTIME_WINDOWS_CMD : UPDATE_RUNTIME_UNIX_CMD,
403                     getRuntimeUpdater().toFile().getAbsolutePath()));
404             System.exit(STOP_EXIT_CODE);
405         }
406     }
407 
408     private boolean checkRuntimeUpdate() throws IOException {
409 
410         // First, delete previous update_runtime.bat
411         Files.deleteIfExists(getRuntimeUpdater());
412 
413         if (Files.isDirectory(basePath.resolve(NEW_DIR).resolve(JRE_DIR))
414                 || Files.isDirectory(basePath.resolve(NEW_DIR).resolve(LAUNCHER_DIR))) {
415 
416             // A new jre or/and a new launcher is available, so generate the script
417             generateRuntimeUpdater();
418             System.out.println(getString("updater.runtimeUpdate", getRuntimeUpdaterFilename()));
419             return true;
420         }
421         return false;
422     }
423 
424     private Path getRuntimeUpdater() {
425 
426         // Get the script file (platform dependent)
427         return basePath.resolve(getRuntimeUpdaterFilename());
428     }
429 
430     private String getRuntimeUpdaterFilename() {
431 
432         return UPDATE_RUNTIME_FILENAME + (isWindowsOS() ? BATCH_WINDOWS_EXT : BATCH_UNIX_EXT);
433     }
434 
435     private boolean isWindowsOS() {
436 
437         return System.getProperty("os.name").startsWith("Windows");
438     }
439 
440     private void generateRuntimeUpdater() throws IOException {
441 
442         // Read resource file
443         InputStream inputStream = getClass().getClassLoader().getResourceAsStream(getRuntimeUpdaterFilename());
444 
445         // Replace NAME pattern
446         List<String> scriptContent = new BufferedReader(new InputStreamReader(Objects.requireNonNull(inputStream))).lines()
447                 .map(line -> line.replaceAll("@NAME@", getName())).collect(Collectors.toList());
448 
449         // Generate the script
450         Files.write(getRuntimeUpdater(), scriptContent, Charset.defaultCharset(), StandardOpenOption.CREATE_NEW);
451     }
452 
453     static class TreeCopier implements FileVisitor<Path> {
454         private final Path source;
455         private final Path target;
456 
457         TreeCopier(Path source, Path target) {
458             this.source = source;
459             this.target = target;
460         }
461 
462         @Override
463         public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
464             // before visiting entries in a directory we create the target directory
465             Path newDir = target.resolve(source.relativize(dir));
466             try {
467                 Files.createDirectories(newDir);
468             } catch (IOException x) {
469                 System.err.format("Unable to create: %s: %s%n", newDir, x);
470                 return SKIP_SUBTREE;
471             }
472             return CONTINUE;
473         }
474 
475         @Override
476         public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
477 
478             try {
479                 Files.copy(file, target.resolve(source.relativize(file)), REPLACE_EXISTING);
480             } catch (IOException e) {
481                 System.err.format("Unable to copy: %s: %s%n", file, e);
482             }
483             return CONTINUE;
484         }
485 
486         @Override
487         public FileVisitResult postVisitDirectory(Path dir, IOException exc) {
488             return CONTINUE;
489         }
490 
491         @Override
492         public FileVisitResult visitFileFailed(Path file, IOException exc) {
493             if (exc instanceof FileSystemLoopException) {
494                 System.err.println("cycle detected: " + file);
495             } else {
496                 System.err.format("Unable to copy: %s: %s%n", file, exc);
497             }
498             return CONTINUE;
499         }
500     }
501 
502     private class RecursiveDeleteFileVisitor extends SimpleFileVisitor<Path> {
503 
504         PathMatcher matcher;
505 
506         RecursiveDeleteFileVisitor(String glob) {
507             super();
508 
509             // Create a matcher according the glob parameter
510             matcher = glob == null ? null : basePath.getFileSystem().getPathMatcher("glob:" + glob);
511         }
512 
513         @Override
514         public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
515 
516             // If the file name matches the glob or if no matcher, delete the file
517             if (matcher == null || matcher.matches(file.getFileName())) {
518                 Files.deleteIfExists(file);
519                 //file.toFile().deleteOnExit(); if needed when IOException is raised
520             }
521             return CONTINUE;
522         }
523 
524         @Override
525         public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
526 
527             // if no matcher, delete the directory
528             if (matcher == null) {
529                 try {
530                     Files.deleteIfExists(dir);
531                 } catch (DirectoryNotEmptyException e) {
532                     // try a second time after a while
533                     try {
534                         Thread.sleep(500);
535                     } catch (InterruptedException ignored) {
536                     }
537                     Files.deleteIfExists(dir); // will re throw a DirectoryNotEmptyException if really is
538                 }
539             }
540 
541             return CONTINUE;
542         }
543 
544     }
545 
546     private static String getString(String resourceName, Object... params) {
547         return MessageFormat.format(bundle.getString(resourceName), params);
548     }
549 
550     private static class UTF8Control extends Control {
551 
552         @Override
553         public ResourceBundle newBundle(String baseName, Locale locale, String format, ClassLoader loader, boolean reload) throws IOException {
554             // The below is a copy of the default implementation.
555             String bundleName = toBundleName(baseName, locale);
556             String resourceName = toResourceName(bundleName, "properties");
557             ResourceBundle bundle = null;
558             InputStream stream = null;
559             if (reload) {
560                 URL url = loader.getResource(resourceName);
561                 if (url != null) {
562                     URLConnection connection = url.openConnection();
563                     if (connection != null) {
564                         connection.setUseCaches(false);
565                         stream = connection.getInputStream();
566                     }
567                 }
568             } else {
569                 stream = loader.getResourceAsStream(resourceName);
570             }
571             if (stream != null) {
572                 try {
573                     // Only this line is changed to make it to read properties files as UTF-8.
574                     bundle = new PropertyResourceBundle(new InputStreamReader(stream, StandardCharsets.UTF_8));
575                 } finally {
576                     stream.close();
577                 }
578             }
579             return bundle;
580         }
581 
582     }
583 }