diff --git a/tools/README b/tools/README new file mode 100644 index 0000000..ff8cc5e --- /dev/null +++ b/tools/README @@ -0,0 +1,14 @@ +This directory contains a set of tools for preparing auto-updates. + + * create-packages.rb + + Given a directory containing the set of files that make up a release, + laid out as they are when installed and a JSON file mapping files + to packages, this tool generates a set of packages for the release + and a file_list.xml listing all the files that make up the release. + + * single-package-map.json + + This is the simplest possible package map, where all files for + a release are placed in a single package. This means that the whole + package will need to be downloaded to install the update. diff --git a/tools/create-packages.rb b/tools/create-packages.rb new file mode 100755 index 0000000..aa83ddf --- /dev/null +++ b/tools/create-packages.rb @@ -0,0 +1,317 @@ +#!/usr/bin/ruby + +require 'rubygems' +require 'find' +require 'json' +require 'rexml/document' +require 'optparse' + +# syntax: +# +# create-packages.rb +# +# Takes the set of files that make up a release and splits them up into +# a set of .zip packages +# +# Outputs: +# +# /$PACKAGE_NAME.zip +# These are packages containing the files in this version of the software. +# +# /file_list.xml +# This file lists all the files contained in this version of the software and +# the packages which they are contained in. +# + +# Represents a group of updates in a release +class UpdateScriptPackage + # name - The name of the package (without any extension) + # hash - The SHA-1 hash of the package + # size - The size of the package in bytes + attr_reader :name,:hash,:size + attr_writer :name,:hash,:size +end + +# Represents a single file in a release +class UpdateScriptFile + # path - The path of the file relative to the installation directory + # hash - The SHA-1 hash of the file + # permissions - The permissions of the file expressed using + # flags from the QFile::Permission enum in Qt + # size - The size of the file in bytes + # package - The name of the package containing this file + attr_reader :path,:hash,:permissions,:size,:package,:target + attr_writer :path,:hash,:permissions,:size,:package,:target +end + +# Utility method - convert a hash map to an REXML element +# +# Hash keys are converted to elements and hash values either +# to text contents or child elements (if the value is a Hash) +# +# 'root' - the root REXML::Element +# 'map' - a hash mapping element names to text contents or +# hash maps +# +def hash_to_xml(root,map) + map.each do |key,value| + element = REXML::Element.new(key) + if value.instance_of?(String) + element.text = value + elsif value.instance_of?(Hash) + hash_to_xml(element,value) + elsif !value.nil? + raise "Unsupported value type #{value.class}" + end + root.add_element element + end + return root +end + +def strip_prefix(string,prefix) + if (!string.start_with?(prefix)) + raise "String does not start with prefix" + end + return string.sub(prefix,"") +end + +class UpdateScriptGenerator + def initialize(input_dir,output_dir, file_list, package_file_map) + # List of files to install in this version + @files_to_install = [] + file_list.each do |path| + file = UpdateScriptFile.new + file.path = strip_prefix(path,input_dir) + + if (File.symlink?(path)) + file.target = File.readlink(path) + else + file.hash = file_sha1(path) + file.permissions = get_file_permissions(path) + file.size = File.size(path) + package_file_map.each do |package,files| + if files.include?(path) + file.package = package + break + end + end + end + + @files_to_install << file + end + + # List of packages containing files for this version + @packages = [] + package_file_map.each do |package_name,files| + path = "#{output_dir}/#{package_name}.zip" + package = UpdateScriptPackage.new + package.name = package_name + package.size = File.size(path) + package.hash = file_sha1(path) + @packages << package + end + end + + def toXML() + doc = REXML::Document.new + update_elem = REXML::Element.new("update") + doc.add_element update_elem + + update_elem.add_element deps_to_xml() + update_elem.add_element packages_to_xml() + update_elem.add_element install_to_xml() + + output = "" + doc.write output + return output + end + + def deps_to_xml() + deps_elem = REXML::Element.new("dependencies") + deps = ["updater.exe"] + deps.each do |dependency| + dep_elem = REXML::Element.new("file") + dep_elem.text = dependency + deps_elem.add_element dep_elem + end + return deps_elem + end + + def packages_to_xml() + packages_elem = REXML::Element.new("packages") + @packages.each do |package| + package_elem = REXML::Element.new("package") + packages_elem.add_element package_elem + hash_to_xml(package_elem,{ + "name" => package.name, + "size" => package.size.to_s, + "hash" => package.hash + }) + end + return packages_elem + end + + def install_to_xml() + install_elem = REXML::Element.new("install") + @files_to_install.each do |file| + file_elem = REXML::Element.new("file") + install_elem.add_element(file_elem) + + attributes = {"name" => file.path} + if (file.target) + attributes["target"] = file.target + else + attributes["size"] = file.size.to_s + attributes["permissions"] = file.permissions.to_s + attributes["hash"] = file.hash + attributes["package"] = file.package + end + hash_to_xml(file_elem,attributes) + end + return install_elem + end + + def file_sha1(path) + return `sha1sum "#{path}"`.split(' ')[0] + end + + # Unix permission flags + # from + S_IRUSR = 0400 + S_IWUSR = 0200 + S_IXUSR = 0100 + S_IRGRP = (S_IRUSR >> 3) + S_IWGRP = (S_IWUSR >> 3) + S_IXGRP = (S_IXUSR >> 3) + S_IROTH = (S_IRGRP >> 3) + S_IWOTH = (S_IWGRP >> 3) + S_IXOTH = (S_IXGRP >> 3) + + # Qt permission flags + # (taken from QFile::Permission) + QT_READ_OWNER = 0x4000 + QT_WRITE_OWNER = 0x2000 + QT_EXEC_OWNER = 0x1000 + QT_READ_USER = 0x0400 + QT_WRITE_USER = 0x0200 + QT_EXEC_USER = 0x0100 + QT_READ_GROUP = 0x0040 + QT_WRITE_GROUP = 0x0020 + QT_EXEC_GROUP = 0x0010 + QT_READ_OTHER = 0x0004 + QT_WRITE_OTHER = 0x0002 + QT_EXEC_OTHER = 0x0001 + + def get_file_permissions(path) + unix_to_qt = { + S_IRUSR => QT_READ_USER | QT_READ_OWNER, + S_IWUSR => QT_WRITE_USER | QT_WRITE_OWNER, + S_IXUSR => QT_EXEC_USER | QT_EXEC_OWNER, + S_IRGRP => QT_READ_GROUP, + S_IWGRP => QT_WRITE_GROUP, + S_IXGRP => QT_EXEC_GROUP, + S_IROTH => QT_READ_OTHER, + S_IWOTH => QT_WRITE_OTHER, + S_IXOTH => QT_EXEC_OTHER + } + + qt_permissions = 0 + unix_permissions = File.stat(path).mode + unix_to_qt.each do |unix_flag,qt_flags| + qt_permissions |= qt_flags if ((unix_permissions & unix_flag) != 0) + end + + return qt_permissions.to_i + end +end + +class PackageMap + def initialize(map_file) + @rule_map = {} + map_json = JSON.parse(File.read(map_file)) + map_json.each do |package,rules| + rules.each do |rule| + rule_regex = Regexp.new(rule) + @rule_map[rule_regex] = package + end + end + end + + def package_for_file(file) + @rule_map.each do |rule,package| + if (file =~ rule) + return package + end + end + return nil + end +end + +OptionParser.new do |parser| + parser.banner = "#{$0} " +end.parse! + +if ARGV.length < 3 + raise "Missing arguments" +end + +input_dir = ARGV[0] +package_map_file = ARGV[1] +output_dir = ARGV[2] + +# get the details of each input file +input_file_list = [] +Find.find(input_dir) do |path| + next if (File.directory?(path)) + input_file_list << path +end + +# map each input file to a corresponding package + +# read the package map +package_map = PackageMap.new(package_map_file) + +# map of package name -> array of files +package_file_map = {} +input_file_list.each do |file| + next if File.symlink?(file) + + package = package_map.package_for_file(file) + if (!package) + raise "Unable to find package for file #{file}" + end + package_file_map[package] = [] if !package_file_map[package] + package_file_map[package] << file +end + +# generate each package +package_file_map.each do |package,files| + puts "Generating package #{package}" + + quoted_files = [] + files.each do |file| + quoted_files << "\"#{strip_prefix(file,input_dir)}\"" + end + quoted_file_list = quoted_files.join(" ") + + output_path = File.expand_path(output_dir) + Dir.chdir(input_dir) do +#if (!system("zip #{output_path}/#{package}.zip #{quoted_file_list}")) +# raise "Failed to generate package #{package}" +# end + end +end + +# output the file_list.xml file +update_script = UpdateScriptGenerator.new(input_dir,output_dir,input_file_list,package_file_map) +output_xml_file = "#{output_dir}/file_list.unformatted.xml" +File.open(output_xml_file,'w') do |file| + file.write update_script.toXML() +end + +# xmllint generates more readable formatted XML than REXML, so write unformatted +# XML first and then format it with xmllint. +system("xmllint --format #{output_xml_file} > #{output_dir}/file_list.xml") +File.delete(output_xml_file) + + diff --git a/tools/single-package-map.json b/tools/single-package-map.json new file mode 100644 index 0000000..97ed49e --- /dev/null +++ b/tools/single-package-map.json @@ -0,0 +1,5 @@ +{ + "app" : [ + ".*" + ] +}