Browse Source

add icon support to pdeploy, and write .desktop files on Linux

rdb 14 years ago
parent
commit
388551f4da
2 changed files with 259 additions and 6 deletions
  1. 232 6
      direct/src/p3d/DeploymentTools.py
  2. 27 0
      direct/src/p3d/pdeploy.py

+ 232 - 6
direct/src/p3d/DeploymentTools.py

@@ -4,12 +4,14 @@ to build for as many platforms as possible. """
 
 
 __all__ = ["Standalone", "Installer"]
 __all__ = ["Standalone", "Installer"]
 
 
-import os, sys, subprocess, tarfile, shutil, time, zipfile, glob, socket, getpass
+import os, sys, subprocess, tarfile, shutil, time, zipfile, glob, socket, getpass, struct
 from cStringIO import StringIO
 from cStringIO import StringIO
 from direct.directnotify.DirectNotifyGlobal import *
 from direct.directnotify.DirectNotifyGlobal import *
 from direct.showbase.AppRunnerGlobal import appRunner
 from direct.showbase.AppRunnerGlobal import appRunner
 from pandac.PandaModules import PandaSystem, HTTPClient, Filename, VirtualFileSystem, Multifile
 from pandac.PandaModules import PandaSystem, HTTPClient, Filename, VirtualFileSystem, Multifile
 from pandac.PandaModules import TiXmlDocument, TiXmlDeclaration, TiXmlElement, readXmlStream
 from pandac.PandaModules import TiXmlDocument, TiXmlDeclaration, TiXmlElement, readXmlStream
+from pandac.PandaModules import PNMImage, PNMFileTypeRegistry
+from direct.stdpy.file import *
 from direct.p3d.HostInfo import HostInfo
 from direct.p3d.HostInfo import HostInfo
 # This is important for some reason
 # This is important for some reason
 import encodings
 import encodings
@@ -126,7 +128,8 @@ class Standalone:
             # Figure out where p3dembed might be now.
             # Figure out where p3dembed might be now.
             if package.platform.startswith("win"):
             if package.platform.startswith("win"):
                 # Use p3dembedw unless console_environment was set.
                 # Use p3dembedw unless console_environment was set.
-                if extraTokens.get("console_environment", self.tokens.get("console_environment", 0)) != 0:
+                cEnv = extraTokens.get("console_environment", self.tokens.get("console_environment", 0))
+                if cEnv != "" and int(cEnv) != 0:
                     p3dembed = Filename(self.host.hostDir, "p3dembed/%s/p3dembed.exe" % package.platform)
                     p3dembed = Filename(self.host.hostDir, "p3dembed/%s/p3dembed.exe" % package.platform)
                 else:
                 else:
                     p3dembed = Filename(self.host.hostDir, "p3dembed/%s/p3dembedw.exe" % package.platform)
                     p3dembed = Filename(self.host.hostDir, "p3dembed/%s/p3dembedw.exe" % package.platform)
@@ -287,6 +290,155 @@ class PackageTree:
         return package
         return package
 
 
 
 
+class Icon:
+    """ This class is used to create an icon for various platforms. """
+    notify = directNotify.newCategory("Icon")
+
+    def __init__(self):
+        self.images = {}
+    
+    def addImage(self, image):
+        """ Adds an image to the icon.  Returns False on failure, True on success.
+        Only one image per size can be loaded, and the image size must be square. """
+
+        if not isinstance(image, PNMImage):
+            fn = image
+            if not isinstance(fn, Filename):
+                fn = Filename.fromOsSpecific(fn)
+
+            image = PNMImage()
+            if not image.read(fn):
+                Icon.notify.warning("Image '%s' could not be read" % fn.getBasename())
+                return False
+
+        if image.getXSize() != image.getYSize():
+            Icon.notify.warning("Ignoring image without square size")
+            return False
+
+        self.images[image.getXSize()] = image
+
+        return True
+
+    def makeICO(self, fn):
+        """ Writes the images to a Windows ICO file.  Returns True on success. """
+
+        if not isinstance(fn, Filename):
+            fn = Filename.fromOsSpecific(fn)
+        fn.setBinary()
+
+        count = 0
+        for size in self.images.keys():
+            if size <= 256:
+                count += 1
+
+        ico = open(fn, 'wb')
+        ico.write(struct.pack('<HHH', 0, 1, count))
+
+        # Write the directory
+        for size, image in self.images.items():
+            if size == 256:
+                ico.write('\0\0')
+            else:
+                ico.write(struct.pack('<BB', size, size))
+            bpp = 32 if image.hasAlpha() else 24
+            ico.write(struct.pack('<BBHHII', 0, 0, 1, bpp, 0, 0))
+
+        # Now write the actual icons
+        ptr = 14
+        for size, image in self.images.items():
+            loc = ico.tell()
+            bpp = 32 if image.hasAlpha() else 24
+            ico.write(struct.pack('<IiiHHIIiiII', 40, size, size * 2, 1, bpp, 0, 0, 0, 0, 0, 0))
+
+            # XOR mask
+            if bpp == 24:
+                # Align rows to 4-byte boundary
+                rowalign = '\0' * (-(size * 3) & 3)
+                for y in xrange(size):
+                    for x in xrange(size):
+                        r, g, b = image.getXel(x, size - y - 1)
+                        ico.write(struct.pack('<BBB', int(b * 255), int(g * 255), int(r * 255)))
+                    ico.write(rowalign)
+            else:
+                for y in xrange(size):
+                    for x in xrange(size):
+                        r, g, b, a = image.getXelA(x, size - y - 1)
+                        ico.write(struct.pack('<BBBB', int(b * 255), int(g * 255), int(r * 255), int(a * 255)))
+
+            # Empty AND mask, aligned to 4-byte boundary
+            #TODO: perhaps we should convert alpha into an AND mask
+            # to support older versions of Windows that don't support alpha.
+            ico.write('\0' * (size * (size / 8 + (-((size / 8) * 3) & 3))))
+
+            # Go back to write the location
+            dataend = ico.tell()
+            ico.seek(ptr)
+            ico.write(struct.pack('<II', dataend - loc, loc))
+            ico.seek(dataend)
+            ptr += 16
+
+        ico.close()
+
+        return True
+
+    def makeICNS(self, fn):
+        """ Writes the images to an Apple ICNS file.  Returns True on success. """
+
+        if not isinstance(fn, Filename):
+            fn = Filename.fromOsSpecific(fn)
+        fn.setBinary()
+
+        vfs = VirtualFileSystem.getGlobalPtr()
+        stream = vfs.openWriteFile(fn, False, True)
+        icns = open(stream, 'wb')
+        icns.write('icns\0\0\0\0')
+
+        icon_types = {16: 'is32', 32: 'il32', 48: 'ih32', 128: 'it32'}
+        mask_types = {16: 's8mk', 32: 'l8mk', 48: 'h8mk', 128: 't8mk'}
+        png_types = {256: 'ic08', 512: 'ic09'}
+
+        pngtype = PNMFileTypeRegistry.getGlobalPtr().getTypeFromExtension("png")
+
+        for size, image in self.images.items():
+            if size in png_types:
+                if pngtype is None:
+                    continue
+                icns.write(png_types[size])
+                icns.write('\0\0\0\0')
+                start = icns.tell()
+
+                image.write(stream, "", pngtype)
+                pngsize = icns.tell() - start
+                icns.seek(start - 4)
+                icns.write(struct.pack('>I', pngsize + 8))
+                icns.seek(start + pngsize)
+
+            elif size in icon_types:
+                icns.write(icon_types[size])
+                icns.write(struct.pack('>I', size * size * 4 + 8))
+
+                for y in xrange(size):
+                    for x in xrange(size):
+                        r, g, b = image.getXel(x, y)
+                        icns.write(struct.pack('>BBBB', 0, int(r * 255), int(g * 255), int(b * 255)))
+
+                if not image.hasAlpha():
+                    continue
+                icns.write(mask_types[size])
+                icns.write(struct.pack('>I', size * size + 8))
+
+                for y in xrange(size):
+                    for x in xrange(size):
+                        icns.write(struct.pack('<B', int(image.getAlpha(x, y) * 255)))
+
+        length = icns.tell()
+        icns.seek(4)
+        icns.write(struct.pack('>I', length))
+        icns.close()
+
+        return True
+
+
 class Installer:
 class Installer:
     """ This class creates a (graphical) installer from a given .p3d file. """
     """ This class creates a (graphical) installer from a given .p3d file. """
     notify = directNotify.newCategory("Installer")
     notify = directNotify.newCategory("Installer")
@@ -303,6 +455,7 @@ class Installer:
         self.authorid = "org.panda3d"
         self.authorid = "org.panda3d"
         self.authorname = os.environ.get("DEBFULLNAME", "")
         self.authorname = os.environ.get("DEBFULLNAME", "")
         self.authoremail = os.environ.get("DEBEMAIL", "")
         self.authoremail = os.environ.get("DEBEMAIL", "")
+        self.icon = None
 
 
         # Try to determine a default author name ourselves.
         # Try to determine a default author name ourselves.
         uname = None
         uname = None
@@ -413,6 +566,7 @@ class Installer:
                 mf.repack()
                 mf.repack()
 
 
                 # If we have no subfiles left, we can just remove the multifile.
                 # If we have no subfiles left, we can just remove the multifile.
+                #XXX rdb: it seems that removing it causes trouble, so let's not.
                 #if mf.getNumSubfiles() == 0:
                 #if mf.getNumSubfiles() == 0:
                 #    Installer.notify.info("Removing empty archive %s" % (package.uncompressedArchive.filename))
                 #    Installer.notify.info("Removing empty archive %s" % (package.uncompressedArchive.filename))
                 #    mf.close()
                 #    mf.close()
@@ -505,8 +659,51 @@ class Installer:
             shutil.copyfile(self.licensefile.toOsSpecific(), Filename(tempdir, "usr/share/doc/%s/copyright" % self.shortname.lower()).toOsSpecific())
             shutil.copyfile(self.licensefile.toOsSpecific(), Filename(tempdir, "usr/share/doc/%s/copyright" % self.shortname.lower()).toOsSpecific())
             shutil.copyfile(self.licensefile.toOsSpecific(), Filename(tempdir, "usr/share/doc/%s/LICENSE" % self.shortname.lower()).toOsSpecific())
             shutil.copyfile(self.licensefile.toOsSpecific(), Filename(tempdir, "usr/share/doc/%s/LICENSE" % self.shortname.lower()).toOsSpecific())
 
 
+        # Add an image file to /usr/share/pixmaps/
+        iconFile = None
+        if self.icon is not None:
+            iconImage = None
+            if 48 in self.icon.images:
+                iconImage = self.icon.images[48]
+            elif 64 in self.icon.images:
+                iconImage = self.icon.images[64]
+            elif 32 in self.icon.images:
+                iconImage = self.icon.images[32]
+            else:
+                Installer.notify.warning("No suitable icon image for Linux provided, should preferably be 48x48 in size")
+
+            if iconImage is not None:
+                iconFile = Filename(tempdir, "usr/share/pixmaps/%s.png" % self.shortname)
+                iconFile.setBinary()
+                iconFile.makeDir()
+                if not iconImage.write(iconFile):
+                    Installer.notify.warning("Failed to write icon file for Linux")
+                    iconFile.unlink()
+                    iconFile = None
+
+        # Write a .desktop file to /usr/share/applications/
+        desktopFile = Filename(tempdir, "usr/share/applications/%s.desktop" % self.shortname.lower())
+        desktopFile.setText()
+        desktopFile.makeDir()
+        desktop = open(desktopFile.toOsSpecific(), 'w')
+        print >>desktop, "[Desktop Entry]"
+        print >>desktop, "Name=%s" % self.fullname
+        print >>desktop, "Exec=%s" % self.shortname.lower()
+        if iconFile is not None:
+            print >>desktop, "Icon=%s" % iconFile.getBasename()
+
+        # Set the "Terminal" option based on whether or not a console env is requested
+        cEnv = self.standalone.tokens.get("console_environment", "")
+        if cEnv == "" or int(cEnv) == 0:
+            print >>desktop, "Terminal=false"
+        else:
+            print >>desktop, "Terminal=true"
+
+        print >>desktop, "Type=Application"
+        desktop.close()
+
         if self.includeRequires:
         if self.includeRequires:
-            hostDir = Filename(tempdir, "usr/lib/" + self.shortname.lower())
+            hostDir = Filename(tempdir, "usr/lib/%s/" % self.shortname.lower())
             hostDir.makeDir()
             hostDir.makeDir()
             self.installPackagesInto(hostDir, platform)
             self.installPackagesInto(hostDir, platform)
 
 
@@ -654,6 +851,11 @@ class Installer:
         hostDir.makeDir()
         hostDir.makeDir()
         self.installPackagesInto(hostDir, platform)
         self.installPackagesInto(hostDir, platform)
 
 
+        hasIcon = False
+        if self.icon is not None:
+            Installer.notify.info("Generating %s.icns..." % self.shortname)
+            hasIcon = self.icon.writeICNS(Filename(hostDir, "%s.icns" % self.shortname))
+
         # Create the application plist file.
         # Create the application plist file.
         # Although it might make more sense to use Python's plistlib module here,
         # Although it might make more sense to use Python's plistlib module here,
         # it is not available on non-OSX systems before Python 2.6.
         # it is not available on non-OSX systems before Python 2.6.
@@ -668,6 +870,9 @@ class Installer:
         print >>plist, '\t<string>%s</string>' % self.fullname
         print >>plist, '\t<string>%s</string>' % self.fullname
         print >>plist, '\t<key>CFBundleExecutable</key>'
         print >>plist, '\t<key>CFBundleExecutable</key>'
         print >>plist, '\t<string>%s</string>' % exefile.getBasename()
         print >>plist, '\t<string>%s</string>' % exefile.getBasename()
+        if hasIcon:
+            print >>plist, '\t<key>CFBundleIconFile</key>'
+            print >>plist, '\t<string>%s</string>' % self.shortname
         print >>plist, '\t<key>CFBundleIdentifier</key>'
         print >>plist, '\t<key>CFBundleIdentifier</key>'
         print >>plist, '\t<string>%s.%s</string>' % (self.authorid, self.shortname)
         print >>plist, '\t<string>%s.%s</string>' % (self.authorid, self.shortname)
         print >>plist, '\t<key>CFBundleInfoDictionaryVersion</key>'
         print >>plist, '\t<key>CFBundleInfoDictionaryVersion</key>'
@@ -860,6 +1065,16 @@ class Installer:
             hostDir.makeDir()
             hostDir.makeDir()
             self.installPackagesInto(hostDir, platform)
             self.installPackagesInto(hostDir, platform)
 
 
+        # See if we can generate an icon
+        icofile = None
+        if self.icon is not None:
+            icofile = Filename(Filename.getTempDirectory(), self.shortname + ".ico")
+            icofile.unlink()
+            Installer.notify.info("Generating %s.ico..." % self.shortname)
+            if not self.icon.writeICO(icofile):
+                icofile = None
+
+        # Create the .nsi installer script
         nsifile = Filename(Filename.getTempDirectory(), self.shortname + ".nsi")
         nsifile = Filename(Filename.getTempDirectory(), self.shortname + ".nsi")
         nsifile.unlink()
         nsifile.unlink()
         nsi = open(nsifile.toOsSpecific(), "w")
         nsi = open(nsifile.toOsSpecific(), "w")
@@ -882,7 +1097,10 @@ class Installer:
         print >>nsi, 'FunctionEnd'
         print >>nsi, 'FunctionEnd'
         print >>nsi
         print >>nsi
         print >>nsi, 'Function desktopshortcut'
         print >>nsi, 'Function desktopshortcut'
-        print >>nsi, '  CreateShortcut "$DESKTOP\\%s.lnk" "$INSTDIR\\%s.exe"' % (self.fullname, self.shortname)
+        if icofile is None:
+            print >>nsi, '  CreateShortcut "$DESKTOP\\%s.lnk" "$INSTDIR\\%s.exe"' % (self.fullname, self.shortname)
+        else:
+            print >>nsi, '  CreateShortcut "$DESKTOP\\%s.lnk" "$INSTDIR\\%s.exe" "" "$INSTDIR\\%s.ico"' % (self.fullname, self.shortname, self.shortname)
         print >>nsi, 'FunctionEnd'
         print >>nsi, 'FunctionEnd'
         print >>nsi
         print >>nsi
         print >>nsi, '!include "MUI2.nsh"'
         print >>nsi, '!include "MUI2.nsh"'
@@ -914,6 +1132,8 @@ class Installer:
         print >>nsi, 'Section "" SecCore'
         print >>nsi, 'Section "" SecCore'
         print >>nsi, '  SetOutPath "$INSTDIR"'
         print >>nsi, '  SetOutPath "$INSTDIR"'
         print >>nsi, '  File "%s"' % exefile.toOsSpecific()
         print >>nsi, '  File "%s"' % exefile.toOsSpecific()
+        if icofile is not None:
+            print >>nsi, '  File "%s"' % icofile.toOsSpecific()
         for f in extrafiles:
         for f in extrafiles:
             print >>nsi, '  File "%s"' % f.toOsSpecific()
             print >>nsi, '  File "%s"' % f.toOsSpecific()
         curdir = ""
         curdir = ""
@@ -931,7 +1151,10 @@ class Installer:
         print >>nsi, '  ; Start menu items'
         print >>nsi, '  ; Start menu items'
         print >>nsi, '  !insertmacro MUI_STARTMENU_WRITE_BEGIN Application'
         print >>nsi, '  !insertmacro MUI_STARTMENU_WRITE_BEGIN Application'
         print >>nsi, '    CreateDirectory "$SMPROGRAMS\\$StartMenuFolder"'
         print >>nsi, '    CreateDirectory "$SMPROGRAMS\\$StartMenuFolder"'
-        print >>nsi, '    CreateShortCut "$SMPROGRAMS\\$StartMenuFolder\\%s.lnk" "$INSTDIR\\%s.exe"' % (self.fullname, self.shortname)
+        if icofile is None:
+            print >>nsi, '    CreateShortCut "$SMPROGRAMS\\$StartMenuFolder\\%s.lnk" "$INSTDIR\\%s.exe"' % (self.fullname, self.shortname)
+        else:
+            print >>nsi, '    CreateShortCut "$SMPROGRAMS\\$StartMenuFolder\\%s.lnk" "$INSTDIR\\%s.exe" "" "$INSTDIR\\%s.ico"' % (self.fullname, self.shortname, self.shortname)
         print >>nsi, '    CreateShortCut "$SMPROGRAMS\\$StartMenuFolder\\Uninstall.lnk" "$INSTDIR\\Uninstall.exe"'
         print >>nsi, '    CreateShortCut "$SMPROGRAMS\\$StartMenuFolder\\Uninstall.lnk" "$INSTDIR\\Uninstall.exe"'
         print >>nsi, '  !insertmacro MUI_STARTMENU_WRITE_END'
         print >>nsi, '  !insertmacro MUI_STARTMENU_WRITE_END'
         print >>nsi, 'SectionEnd'
         print >>nsi, 'SectionEnd'
@@ -939,6 +1162,8 @@ class Installer:
         # This section defines the uninstaller.
         # This section defines the uninstaller.
         print >>nsi, 'Section Uninstall'
         print >>nsi, 'Section Uninstall'
         print >>nsi, '  Delete "$INSTDIR\\%s.exe"' % self.shortname
         print >>nsi, '  Delete "$INSTDIR\\%s.exe"' % self.shortname
+        if icofile is not None:
+            print >>nsi, '  Delete "$INSTDIR\\%s.ico"' % self.shortname
         for f in extrafiles:
         for f in extrafiles:
             print >>nsi, '  Delete "%s"' % f.getBasename()
             print >>nsi, '  Delete "%s"' % f.getBasename()
         print >>nsi, '  Delete "$INSTDIR\\Uninstall.exe"'
         print >>nsi, '  Delete "$INSTDIR\\Uninstall.exe"'
@@ -966,7 +1191,8 @@ class Installer:
             self.notify.warning("Unable to invoke NSIS command.")
             self.notify.warning("Unable to invoke NSIS command.")
 
 
         nsifile.unlink()
         nsifile.unlink()
-        # TODO: should empty hostDir here to clean up after ourselves.
+        if icofile is not None:
+            icofile.unlink()
         
         
         return output
         return output
 
 

+ 27 - 0
direct/src/p3d/pdeploy.py

@@ -119,6 +119,18 @@ Options:
      is username@hostname.
      is username@hostname.
      Only relevant when generating a graphical installer.
      Only relevant when generating a graphical installer.
 
 
+  -i "path/icon32.png" -i "path/icon48.png" -i "path/icon128.png"
+     This option should be repeated several times with different square
+     image sizes.  These images will then be combined to form an icon
+     file that will be used to represent the installed application.
+     To support all platforms, it is recommended to supply images of
+     the sizes 16x16, 32x32, 48x48, 128x128, 256x256, and 512x512.
+     The larger icon sizes can be omitted can safely be omitted if
+     you cannot provide images in that resolution.
+     It is recommended to use .png images for correct transparency.
+     If no images are provided, no icon will be generated.
+     Only relevant when generating a graphical installer.
+
   -h
   -h
      Display this help
      Display this help
 
 
@@ -150,6 +162,7 @@ licensefile = Filename()
 authorid = ""
 authorid = ""
 authorname = ""
 authorname = ""
 authoremail = ""
 authoremail = ""
+iconFiles = []
 includeRequires = False
 includeRequires = False
 
 
 try:
 try:
@@ -185,6 +198,8 @@ for opt, arg in opts:
         authorname = arg.strip()
         authorname = arg.strip()
     elif opt == '-e':
     elif opt == '-e':
         authoremail = arg.strip()
         authoremail = arg.strip()
+    elif opt == '-i':
+        iconFiles.append(Filename.fromOsSpecific(arg))
 
 
     elif opt == '-h':
     elif opt == '-h':
         usage(0)
         usage(0)
@@ -264,6 +279,16 @@ elif deploy_mode == 'installer':
         print "Using author \"%s\" <%s> with ID %s" % \
         print "Using author \"%s\" <%s> with ID %s" % \
             (i.authorname, i.authoremail, i.authorid)
             (i.authorname, i.authoremail, i.authorid)
 
 
+    # Add the supplied icon images
+    failed = False
+    for iconFile in iconFiles:
+        if not i.icon.addImage(iconFile):
+            print '\nFailed to add icon image "%s"!\n' % iconFile
+            failed = True
+    if failed:
+        sys.exit(1)
+
+    # Now build for the requested platforms.
     if currentPlatform:
     if currentPlatform:
         platform = PandaSystem.getPlatform()
         platform = PandaSystem.getPlatform()
         if platform.startswith("win"):
         if platform.startswith("win"):
@@ -278,6 +303,8 @@ elif deploy_mode == 'installer':
             output.makeDir()
             output.makeDir()
             i.build(output, platform)
             i.build(output, platform)
 
 
+    del i
+
 elif deploy_mode == 'html':
 elif deploy_mode == 'html':
     w, h = tokens.get("width", 640), tokens.get("height", 480)
     w, h = tokens.get("width", 640), tokens.get("height", 480)
     if "data" not in tokens:
     if "data" not in tokens: