1 /*
2  * PluginManager.java 24 oct. 2008
3  *
4  * Sweet Home 3D, Copyright (c) 2008 Emmanuel PUYBARET / eTeks <info@eteks.com>
5  *
6  * This program is free software; you can redistribute it and/or modify
7  * it under the terms of the GNU General Public License as published by
8  * the Free Software Foundation; either version 2 of the License, or
9  * (at your option) any later version.
10  *
11  * This program is distributed in the hope that it will be useful,
12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14  * GNU General Public License for more details.
15  *
16  * You should have received a copy of the GNU General Public License
17  * along with this program; if not, write to the Free Software
18  * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
19  */
20 package com.eteks.sweethome3d.plugin;
21 
22 import java.io.BufferedInputStream;
23 import java.io.File;
24 import java.io.FileFilter;
25 import java.io.FileInputStream;
26 import java.io.FileOutputStream;
27 import java.io.IOException;
28 import java.io.InputStream;
29 import java.io.OutputStream;
30 import java.lang.reflect.Constructor;
31 import java.lang.reflect.Modifier;
32 import java.net.MalformedURLException;
33 import java.net.URL;
34 import java.net.URLClassLoader;
35 import java.net.URLEncoder;
36 import java.util.ArrayList;
37 import java.util.Arrays;
38 import java.util.Collections;
39 import java.util.Iterator;
40 import java.util.LinkedHashMap;
41 import java.util.List;
42 import java.util.Locale;
43 import java.util.Map;
44 import java.util.MissingResourceException;
45 import java.util.ResourceBundle;
46 import java.util.TreeMap;
47 import java.util.zip.ZipEntry;
48 import java.util.zip.ZipInputStream;
49 
50 import javax.swing.undo.UndoableEditSupport;
51 
52 import com.eteks.sweethome3d.model.CollectionEvent;
53 import com.eteks.sweethome3d.model.CollectionListener;
54 import com.eteks.sweethome3d.model.Home;
55 import com.eteks.sweethome3d.model.HomeApplication;
56 import com.eteks.sweethome3d.model.Library;
57 import com.eteks.sweethome3d.model.RecorderException;
58 import com.eteks.sweethome3d.model.UserPreferences;
59 import com.eteks.sweethome3d.tools.OperatingSystem;
60 import com.eteks.sweethome3d.viewcontroller.HomeController;
61 
62 /**
63  * Sweet Home 3D plug-ins manager.
64  * @author Emmanuel Puybaret
65  */
66 public class PluginManager {
67   public static final String PLUGIN_LIBRARY_TYPE = "Plugin";
68 
69   private static final String ID                          = "id";
70   private static final String NAME                        = "name";
71   private static final String CLASS                       = "class";
72   private static final String DESCRIPTION                 = "description";
73   private static final String VERSION                     = "version";
74   private static final String LICENSE                     = "license";
75   private static final String PROVIDER                    = "provider";
76   private static final String APPLICATION_MINIMUM_VERSION = "applicationMinimumVersion";
77   private static final String JAVA_MINIMUM_VERSION        = "javaMinimumVersion";
78 
79   private static final String APPLICATION_PLUGIN_FAMILY   = "ApplicationPlugin";
80 
81   private static final String DEFAULT_APPLICATION_PLUGIN_PROPERTIES_FILE =
82       APPLICATION_PLUGIN_FAMILY + ".properties";
83 
84   private final File [] pluginFolders;
85   private final Map<String, PluginLibrary> pluginLibraries =
86       new TreeMap<String, PluginLibrary>();
87   private final Map<Home, List<Plugin>> homePlugins = new LinkedHashMap<Home, List<Plugin>>();
88 
89   /**
90    * Reads application plug-ins from resources in the given plug-in folder.
91    */
PluginManager(File pluginFolder)92   public PluginManager(File pluginFolder) {
93     this(new File [] {pluginFolder});
94   }
95 
96   /**
97    * Reads application plug-ins from resources in the given plug-in folders.
98    * @since 3.0
99    */
PluginManager(File [] pluginFolders)100   public PluginManager(File [] pluginFolders) {
101     this.pluginFolders = pluginFolders;
102     if (pluginFolders != null) {
103       for (File pluginFolder : pluginFolders) {
104         // Try to load plugin files from plugin folder
105         File [] pluginFiles = pluginFolder.listFiles(new FileFilter () {
106           public boolean accept(File pathname) {
107             return pathname.isFile();
108           }
109         });
110 
111         if (pluginFiles != null) {
112           // Treat plug in files in reverse order of their version number
113           Arrays.sort(pluginFiles, Collections.reverseOrder(OperatingSystem.getFileVersionComparator()));
114           for (File pluginFile : pluginFiles) {
115             try {
116               loadPlugins(pluginFile.toURI().toURL(), pluginFile.getAbsolutePath());
117             } catch (MalformedURLException ex) {
118               // Files are supposed to exist !
119             }
120           }
121         }
122       }
123     }
124   }
125 
126   /**
127    * Reads application plug-ins from resources in the given URLs.
128    */
PluginManager(URL [] pluginUrls)129   public PluginManager(URL [] pluginUrls) {
130     this.pluginFolders = null;
131     for (URL pluginUrl : pluginUrls) {
132       loadPlugins(pluginUrl, pluginUrl.toExternalForm());
133     }
134   }
135 
136   /**
137    * Loads the plug-ins that may be available in the given URL.
138    */
loadPlugins(URL pluginUrl, String pluginLocation)139   private void loadPlugins(URL pluginUrl, String pluginLocation) {
140     ZipInputStream zipIn = null;
141     try {
142       // Open a zip input from pluginUrl
143       zipIn = new ZipInputStream(pluginUrl.openStream());
144       // Try do find a plugin properties file in current zip stream
145       for (ZipEntry entry; (entry = zipIn.getNextEntry()) != null; ) {
146         String zipEntryName = entry.getName();
147         int lastIndex = zipEntryName.lastIndexOf(DEFAULT_APPLICATION_PLUGIN_PROPERTIES_FILE);
148         if (lastIndex != -1
149             && (lastIndex == 0
150                 || zipEntryName.charAt(lastIndex - 1) == '/')) {
151           try {
152             // Build application plugin family with its package
153             String applicationPluginFamily = zipEntryName.substring(0, lastIndex);
154             applicationPluginFamily += APPLICATION_PLUGIN_FAMILY;
155             ClassLoader classLoader = new URLClassLoader(new URL [] {pluginUrl}, getClass().getClassLoader());
156             readPlugin(ResourceBundle.getBundle(applicationPluginFamily, Locale.getDefault(), classLoader),
157                 pluginLocation,
158                 "jar:" + pluginUrl.toString() + "!/" + URLEncoder.encode(zipEntryName, "UTF-8").replace("+", "%20"),
159                 classLoader);
160           } catch (MissingResourceException ex) {
161             // Ignore malformed plugins
162           }
163         }
164       }
165     } catch (IOException ex) {
166       // Ignore furniture plugin
167     } finally {
168       if (zipIn != null) {
169         try {
170           zipIn.close();
171         } catch (IOException ex) {
172         }
173       }
174     }
175   }
176 
177   /**
178    * Reads the plug-in properties from the given <code>resource</code>.
179    */
readPlugin(ResourceBundle resource, String pluginLocation, String pluginEntry, ClassLoader pluginClassLoader)180   private void readPlugin(ResourceBundle resource,
181                           String         pluginLocation,
182                           String         pluginEntry,
183                           ClassLoader    pluginClassLoader) {
184     try {
185       String name = resource.getString(NAME);
186 
187       // Check Java and application versions
188       String javaMinimumVersion = resource.getString(JAVA_MINIMUM_VERSION);
189       if (!OperatingSystem.isJavaVersionGreaterOrEqual(javaMinimumVersion)) {
190         System.err.println("Invalid plug-in " + pluginEntry + ":\n"
191             + "Not compatible Java version " + System.getProperty("java.version"));
192         return;
193       }
194 
195       String applicationMinimumVersion = resource.getString(APPLICATION_MINIMUM_VERSION);
196       if (!isApplicationVersionSuperiorTo(applicationMinimumVersion)) {
197         System.err.println("Invalid plug-in " + pluginEntry + ":\n"
198             + "Not compatible application version");
199         return;
200       }
201 
202       String pluginClassName = resource.getString(CLASS);
203       Class<? extends Plugin> pluginClass = getPluginClass(pluginClassLoader, pluginClassName);
204 
205       String id = getOptionalString(resource, ID, null);
206       String description = resource.getString(DESCRIPTION);
207       String version = resource.getString(VERSION);
208       String license = resource.getString(LICENSE);
209       String provider = resource.getString(PROVIDER);
210 
211       // Store plug-in properties if they don't exist yet
212       if (this.pluginLibraries.get(name) == null) {
213         this.pluginLibraries.put(name, new PluginLibrary(
214             pluginLocation, id, name, description, version, license, provider, pluginClass, pluginClassLoader));
215       }
216     } catch (MissingResourceException ex) {
217       System.err.println("Invalid plug-in " + pluginEntry + ":\n" + ex.getMessage());
218     } catch (IllegalArgumentException ex) {
219       System.err.println("Invalid plug-in " + pluginEntry + ":\n" + ex.getMessage());
220     }
221   }
222 
223   /**
224    * Returns the value of the property with the given <code>key</code> or the default value
225    * if the property isn't defined.
226    */
getOptionalString(ResourceBundle resource, String key, String defaultValue)227   private String getOptionalString(ResourceBundle resource, String key, String defaultValue) {
228     try {
229       return resource.getString(key);
230     } catch (MissingResourceException ex) {
231       return defaultValue;
232     }
233   }
234 
235   /**
236    * Returns <code>true</code> if the given version is smaller than the version
237    * of the application. Versions are compared only on their first two parts.
238    */
isApplicationVersionSuperiorTo(String applicationMinimumVersion)239   private boolean isApplicationVersionSuperiorTo(String applicationMinimumVersion) {
240     String [] applicationMinimumVersionParts = applicationMinimumVersion.split("\\.|_|\\s");
241     if (applicationMinimumVersionParts.length >= 1) {
242       try {
243         // Compare digits in first part
244         int applicationVersionFirstPart = (int)(Home.CURRENT_VERSION / 1000);
245         int applicationMinimumVersionFirstPart = Integer.parseInt(applicationMinimumVersionParts [0]);
246         if (applicationVersionFirstPart > applicationMinimumVersionFirstPart) {
247           return true;
248         } else if (applicationVersionFirstPart == applicationMinimumVersionFirstPart
249                    && applicationMinimumVersionParts.length >= 2) {
250           // Compare digits in second part
251           return ((Home.CURRENT_VERSION / 100) % 10) >= Integer.parseInt(applicationMinimumVersionParts [1]);
252         }
253       } catch (NumberFormatException ex) {
254       }
255     }
256     return false;
257   }
258 
259   /**
260    * Returns the <code>Class</code> instance of the class named <code>pluginClassName</code>,
261    * after checking plug-in class exists, may be instantiated and has a default public constructor.
262    */
263   @SuppressWarnings("unchecked")
getPluginClass(ClassLoader pluginClassLoader, String pluginClassName)264   private Class<? extends Plugin> getPluginClass(ClassLoader pluginClassLoader,
265                                                  String pluginClassName) {
266     try {
267       Class<? extends Plugin> pluginClass =
268           (Class<? extends Plugin>)pluginClassLoader.loadClass(pluginClassName);
269       if (!Plugin.class.isAssignableFrom(pluginClass)) {
270         throw new IllegalArgumentException(
271             pluginClassName + " not a subclass of " + Plugin.class.getName());
272       } else if (Modifier.isAbstract(pluginClass.getModifiers())
273                  || !Modifier.isPublic(pluginClass.getModifiers())) {
274         throw new IllegalArgumentException(
275             pluginClassName + " not a public static class");
276       }
277       Constructor<? extends Plugin> constructor = pluginClass.getConstructor(new Class [0]);
278       if (!Modifier.isPublic(constructor.getModifiers())) {
279         throw new IllegalArgumentException(
280             pluginClassName + " constructor not accessible");
281       }
282       return pluginClass;
283     } catch (NoClassDefFoundError ex) {
284       throw new IllegalArgumentException(ex.getMessage(), ex);
285     } catch (ClassNotFoundException ex) {
286       throw new IllegalArgumentException(ex.getMessage(), ex);
287     } catch (NoSuchMethodException ex) {
288       throw new IllegalArgumentException(ex.getMessage(), ex);
289     }
290   }
291 
292   /**
293    * Returns the available plug-in libraries.
294    * @since 4.0
295    */
getPluginLibraries()296   public List<Library> getPluginLibraries() {
297     return Collections.unmodifiableList(new ArrayList<Library>(this.pluginLibraries.values()));
298   }
299 
300   /**
301    * Returns an unmodifiable list of plug-in instances initialized with the
302    * given parameters.
303    */
getPlugins(final HomeApplication application, final Home home, UserPreferences preferences, UndoableEditSupport undoSupport)304   public List<Plugin> getPlugins(final HomeApplication application,
305                                  final Home home,
306                                  UserPreferences preferences,
307                                  UndoableEditSupport undoSupport) {
308     return getPlugins(application, home, preferences, null, undoSupport);
309   }
310 
311   /**
312    * Returns an unmodifiable list of plug-in instances initialized with the
313    * given parameters.
314    * @since 3.5
315    */
getPlugins(final HomeApplication application, final Home home, UserPreferences preferences, HomeController homeController, UndoableEditSupport undoSupport)316   List<Plugin> getPlugins(final HomeApplication application,
317                           final Home home,
318                           UserPreferences preferences,
319                           HomeController homeController,
320                           UndoableEditSupport undoSupport) {
321     if (application.getHomes().contains(home)) {
322       List<Plugin> plugins = this.homePlugins.get(home);
323       if (plugins == null) {
324         plugins = new ArrayList<Plugin>();
325         // Instantiate each plug-in class
326         for (PluginLibrary pluginLibrary : this.pluginLibraries.values()) {
327           try {
328             Plugin plugin = pluginLibrary.getPluginClass().newInstance();
329             plugin.setPluginClassLoader(pluginLibrary.getPluginClassLoader());
330             plugin.setName(pluginLibrary.getName());
331             plugin.setDescription(pluginLibrary.getDescription());
332             plugin.setVersion(pluginLibrary.getVersion());
333             plugin.setLicense(pluginLibrary.getLicense());
334             plugin.setProvider(pluginLibrary.getProvider());
335             plugin.setUserPreferences(preferences);
336             plugin.setHome(home);
337             plugin.setHomeController(homeController);
338             plugin.setUndoableEditSupport(undoSupport);
339             plugins.add(plugin);
340           } catch (InstantiationException ex) {
341             // Shouldn't happen : plug-in class was checked during readPlugin call
342             throw new RuntimeException(ex);
343           } catch (IllegalAccessException ex) {
344             // Shouldn't happen : plug-in class was checked during readPlugin call
345             throw new RuntimeException(ex);
346           }
347         }
348 
349         plugins = Collections.unmodifiableList(plugins);
350         this.homePlugins.put(home, plugins);
351 
352         // Initialize plug-ins
353         for (Plugin plugin : plugins) {
354           plugin.init();
355         }
356 
357         // Add a listener that will destroy all plug-ins when home is deleted
358         application.addHomesListener(new CollectionListener<Home>() {
359             public void collectionChanged(CollectionEvent<Home> ev) {
360               if (ev.getType() == CollectionEvent.Type.DELETE
361                   && ev.getItem() == home) {
362                 for (Plugin plugin : homePlugins.get(home)) {
363                   plugin.destroy();
364                 }
365                 homePlugins.remove(home);
366                 application.removeHomesListener(this);
367               }
368             }
369           });
370       }
371       return plugins;
372     } else {
373       return Collections.emptyList();
374     }
375   }
376 
377   /**
378    * Returns <code>true</code> if a plug-in in the given file name already exists
379    * in the first plug-ins folder.
380    * @throws RecorderException if no plug-ins folder is associated to this manager.
381    */
pluginExists(String pluginLocation)382   public boolean pluginExists(String pluginLocation) throws RecorderException {
383     if (this.pluginFolders == null
384         || this.pluginFolders.length == 0) {
385       throw new RecorderException("Can't access to plugins folder");
386     } else {
387       String pluginFileName = new File(pluginLocation).getName();
388       return new File(this.pluginFolders [0], pluginFileName).exists();
389     }
390   }
391 
392   /**
393    * Deletes the given plug-in <code>libraries</code> from managed plug-ins.
394    * @since 4.0
395    */
deletePlugins(List<Library> libraries)396   public void deletePlugins(List<Library> libraries) throws RecorderException {
397     for (Library library : libraries) {
398       for (Iterator<Map.Entry<String, PluginLibrary>> it = this.pluginLibraries.entrySet().iterator(); it.hasNext(); ) {
399         String pluginLocation = it.next().getValue().getLocation();
400         if (pluginLocation.equals(library.getLocation())) {
401           if (new File(pluginLocation).exists()
402               && !new File(pluginLocation).delete()) {
403             throw new RecorderException("Couldn't delete file " + library.getLocation());
404           }
405           it.remove();
406         }
407       }
408     }
409   }
410 
411   /**
412    * Adds the file at the given location to the first plug-ins folders if it exists.
413    * Once added, the plug-in will be available at next application start.
414    * @throws RecorderException if no plug-ins folder is associated to this manager.
415    */
addPlugin(String pluginPath)416   public void addPlugin(String pluginPath) throws RecorderException {
417     try {
418       if (this.pluginFolders == null
419           || this.pluginFolders.length == 0) {
420         throw new RecorderException("Can't access to plugins folder");
421       }
422       String pluginFileName = new File(pluginPath).getName();
423       File destinationFile = new File(this.pluginFolders [0], pluginFileName);
424 
425       // Copy furnitureCatalogFile to furniture plugin folder
426       InputStream tempIn = null;
427       OutputStream tempOut = null;
428       try {
429         tempIn = new BufferedInputStream(new FileInputStream(pluginPath));
430         this.pluginFolders [0].mkdirs();
431         tempOut = new FileOutputStream(destinationFile);
432         byte [] buffer = new byte [8192];
433         int size;
434         while ((size = tempIn.read(buffer)) != -1) {
435           tempOut.write(buffer, 0, size);
436         }
437       } finally {
438         if (tempIn != null) {
439           tempIn.close();
440         }
441         if (tempOut != null) {
442           tempOut.close();
443         }
444       }
445     } catch (IOException ex) {
446       throw new RecorderException(
447           "Can't write " + pluginPath +  " in plugins folder", ex);
448     }
449   }
450 
451   /**
452    * The properties required to instantiate a plug-in.
453    */
454   private static class PluginLibrary implements Library {
455     private final String                  location;
456     private final String                  name;
457     private final String                  id;
458     private final String                  description;
459     private final String                  version;
460     private final String                  license;
461     private final String                  provider;
462     private final Class<? extends Plugin> pluginClass;
463     private final ClassLoader             pluginClassLoader;
464 
465     /**
466      * Creates plug-in properties from parameters.
467      */
PluginLibrary(String location, String id, String name, String description, String version, String license, String provider, Class<? extends Plugin> pluginClass, ClassLoader pluginClassLoader)468     public PluginLibrary(String location,
469                          String id,
470                          String name, String description, String version,
471                          String license, String provider,
472                          Class<? extends Plugin> pluginClass, ClassLoader pluginClassLoader) {
473       this.location = location;
474       this.id = id;
475       this.name = name;
476       this.description = description;
477       this.version = version;
478       this.license = license;
479       this.provider = provider;
480       this.pluginClass = pluginClass;
481       this.pluginClassLoader = pluginClassLoader;
482     }
483 
getPluginClass()484     public Class<? extends Plugin> getPluginClass() {
485       return this.pluginClass;
486     }
487 
getPluginClassLoader()488     public ClassLoader getPluginClassLoader() {
489       return this.pluginClassLoader;
490     }
491 
getType()492     public String getType() {
493       return PluginManager.PLUGIN_LIBRARY_TYPE;
494     }
495 
getLocation()496     public String getLocation() {
497       return this.location;
498     }
499 
getId()500     public String getId() {
501       return this.id;
502     }
503 
getName()504     public String getName() {
505       return this.name;
506     }
507 
getDescription()508     public String getDescription() {
509       return this.description;
510     }
511 
getVersion()512     public String getVersion() {
513       return this.version;
514     }
515 
getLicense()516     public String getLicense() {
517       return this.license;
518     }
519 
getProvider()520     public String getProvider() {
521       return this.provider;
522     }
523   }
524 }
525