aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md42
-rw-r--r--app.rb76
-rwxr-xr-xplist.rb21
-rwxr-xr-xplist/generator.rb229
-rwxr-xr-xplist/parser.rb225
-rwxr-xr-xpublic/images/arrow-270.pngbin0 -> 622 bytes
-rwxr-xr-xpublic/images/bg.pngbin0 -> 199 bytes
-rwxr-xr-xpublic/images/bg_sub.pngbin0 -> 165 bytes
-rwxr-xr-xpublic/images/information.pngbin0 -> 744 bytes
-rw-r--r--views/index.erb88
10 files changed, 681 insertions, 0 deletions
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..dcefa72
--- /dev/null
+++ b/README.md
@@ -0,0 +1,42 @@
+##ABOUT##
+
+SOUP Kitchen is a simple web front-end to OS X Server's Software Update service which allows users to browse and download the available packages.
+
+
+##USAGE##
+
+> sudo gem install sinatra
+> cd soup\_kitchen
+> ruby -rubygems app.rb http://your.sus.server.com:8080/index.sucatalog
+
+Browse to http://localhost:4567
+
+The update catalog is loaded on startup. A reload can be forced by visiting /reload. Clicking the download link will download the *biggest package from that update* (see bugs below). The info button will take you to the metadata file for that update.
+
+
+##BUGS##
+
+- The download links are not entirely accurate. Most updates these days are complex distributions, consisting of several parts and dependencies which SOUP Kitchen cannot handle properly. The download link simply points to the largest package of a Software Update, which is typically what one wants, but not always.
+
+- Only English packages can be downloaded
+
+- It takes quite a while for the app to start up. This is because in order to get the proper title of an Update, the metadata file must be fetched from the server. For a server that mirrors all updates, this means about 400 requests to the server. This has been tested with the options "Copy all updates from Apple" and "Delete outdated software updates"
+
+- The number of packages doesn't always seem to match the one in Server Admin
+
+
+## CREDITS ##
+
+- Icons from [Yusuke Kamiyamane](http://p.yusukekamiyamane.com/)
+- PLIST parsing done using the excellent PLIST Ruby library [from these guys](http://plist.rubyforge.org/)
+
+
+## LICENSE##
+
+Copyright (c) 2012 Filipp Lepalaan
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/app.rb b/app.rb
new file mode 100644
index 0000000..3aeaadb
--- /dev/null
+++ b/app.rb
@@ -0,0 +1,76 @@
+require "plist"
+require "sinatra"
+require "open-uri"
+
+@@url = ARGV[0] # the URL of index.sucatalog
+
+def update
+
+ @@products = Array.new # the array holding all the available updates
+
+ begin
+ p "Loading catalog from #{@@url}..."
+ @@plist = Plist::parse_xml(open(@@url))
+ p "#{@@plist['Products'].count} total products found"
+ rescue OpenURI::HTTPError
+ p "Failed to connect to #{@@url}"
+ exit
+ end
+
+ @@plist['Products'].each do |k, v|
+
+ begin
+
+ size = 0
+ prod = Hash.new
+ mdurl = v['ServerMetadataURL'];
+ md = Plist::parse_xml(open(mdurl))
+ prod['PostDate'] = v['PostDate']
+ prod['date'] = v['PostDate'].strftime("%d.%m.%y")
+ prod['version'] = md['CFBundleShortVersionString']
+ prod['title'] = md['localization']['English']['title']
+
+ psize = 0
+ v['Packages'].each do |p|
+
+ if p['Size'] > psize
+ prod['url'] = p['URL']
+ end
+
+ psize = p['Size']
+ size += p['Size']
+
+ end
+
+ prod['mdurl'] = mdurl
+ prod['size'] = size/1024/1024
+ @@products.push(prod)
+
+ rescue OpenURI::HTTPError, NoMethodError
+ # just ignore
+ end
+
+ end
+
+ # sort by date, desc
+ @@products = @@products.sort_by { |p| p['PostDate'] }
+ @@products.reverse!
+
+ @@title = "#{@@products.count} updates in Software Update Depot"
+
+end
+
+update()
+
+get "/" do
+
+ erb:index
+
+end
+
+get "/reload" do
+
+ update()
+ redirect "/", 303
+
+end
diff --git a/plist.rb b/plist.rb
new file mode 100755
index 0000000..71f2714
--- /dev/null
+++ b/plist.rb
@@ -0,0 +1,21 @@
+#!/usr/bin/env ruby
+#
+# = plist
+#
+# This is the main file for plist. Everything interesting happens in
+# Plist and Plist::Emit.
+#
+# Copyright 2006-2010 Ben Bleything and Patrick May
+# Distributed under the MIT License
+#
+
+require 'base64'
+require 'cgi'
+require 'stringio'
+
+require 'plist/generator'
+require 'plist/parser'
+
+module Plist
+ VERSION = '3.1.0'
+end
diff --git a/plist/generator.rb b/plist/generator.rb
new file mode 100755
index 0000000..02958c5
--- /dev/null
+++ b/plist/generator.rb
@@ -0,0 +1,229 @@
+#!/usr/bin/env ruby
+#
+# = plist
+#
+# Copyright 2006-2010 Ben Bleything and Patrick May
+# Distributed under the MIT License
+#
+
+module Plist ; end
+
+# === Create a plist
+# You can dump an object to a plist in one of two ways:
+#
+# * <tt>Plist::Emit.dump(obj)</tt>
+# * <tt>obj.to_plist</tt>
+# * This requires that you mixin the <tt>Plist::Emit</tt> module, which is already done for +Array+ and +Hash+.
+#
+# The following Ruby classes are converted into native plist types:
+# Array, Bignum, Date, DateTime, Fixnum, Float, Hash, Integer, String, Symbol, Time, true, false
+# * +Array+ and +Hash+ are both recursive; their elements will be converted into plist nodes inside the <array> and <dict> containers (respectively).
+# * +IO+ (and its descendants) and +StringIO+ objects are read from and their contents placed in a <data> element.
+# * User classes may implement +to_plist_node+ to dictate how they should be serialized; otherwise the object will be passed to <tt>Marshal.dump</tt> and the result placed in a <data> element.
+#
+# For detailed usage instructions, refer to USAGE[link:files/docs/USAGE.html] and the methods documented below.
+module Plist::Emit
+ # Helper method for injecting into classes. Calls <tt>Plist::Emit.dump</tt> with +self+.
+ def to_plist(envelope = true)
+ return Plist::Emit.dump(self, envelope)
+ end
+
+ # Helper method for injecting into classes. Calls <tt>Plist::Emit.save_plist</tt> with +self+.
+ def save_plist(filename)
+ Plist::Emit.save_plist(self, filename)
+ end
+
+ # The following Ruby classes are converted into native plist types:
+ # Array, Bignum, Date, DateTime, Fixnum, Float, Hash, Integer, String, Symbol, Time
+ #
+ # Write us (via RubyForge) if you think another class can be coerced safely into one of the expected plist classes.
+ #
+ # +IO+ and +StringIO+ objects are encoded and placed in <data> elements; other objects are <tt>Marshal.dump</tt>'ed unless they implement +to_plist_node+.
+ #
+ # The +envelope+ parameters dictates whether or not the resultant plist fragment is wrapped in the normal XML/plist header and footer. Set it to false if you only want the fragment.
+ def self.dump(obj, envelope = true)
+ output = plist_node(obj)
+
+ output = wrap(output) if envelope
+
+ return output
+ end
+
+ # Writes the serialized object's plist to the specified filename.
+ def self.save_plist(obj, filename)
+ File.open(filename, 'wb') do |f|
+ f.write(obj.to_plist)
+ end
+ end
+
+ private
+ def self.plist_node(element)
+ output = ''
+
+ if element.respond_to? :to_plist_node
+ output << element.to_plist_node
+ else
+ case element
+ when Array
+ if element.empty?
+ output << "<array/>\n"
+ else
+ output << tag('array') {
+ element.collect {|e| plist_node(e)}
+ }
+ end
+ when Hash
+ if element.empty?
+ output << "<dict/>\n"
+ else
+ inner_tags = []
+
+ element.keys.sort.each do |k|
+ v = element[k]
+ inner_tags << tag('key', CGI::escapeHTML(k.to_s))
+ inner_tags << plist_node(v)
+ end
+
+ output << tag('dict') {
+ inner_tags
+ }
+ end
+ when true, false
+ output << "<#{element}/>\n"
+ when Time
+ output << tag('date', element.utc.strftime('%Y-%m-%dT%H:%M:%SZ'))
+ when Date # also catches DateTime
+ output << tag('date', element.strftime('%Y-%m-%dT%H:%M:%SZ'))
+ when String, Symbol, Fixnum, Bignum, Integer, Float
+ output << tag(element_type(element), CGI::escapeHTML(element.to_s))
+ when IO, StringIO
+ element.rewind
+ contents = element.read
+ # note that apple plists are wrapped at a different length then
+ # what ruby's base64 wraps by default.
+ # I used #encode64 instead of #b64encode (which allows a length arg)
+ # because b64encode is b0rked and ignores the length arg.
+ data = "\n"
+ Base64::encode64(contents).gsub(/\s+/, '').scan(/.{1,68}/o) { data << $& << "\n" }
+ output << tag('data', data)
+ else
+ output << comment( 'The <data> element below contains a Ruby object which has been serialized with Marshal.dump.' )
+ data = "\n"
+ Base64::encode64(Marshal.dump(element)).gsub(/\s+/, '').scan(/.{1,68}/o) { data << $& << "\n" }
+ output << tag('data', data )
+ end
+ end
+
+ return output
+ end
+
+ def self.comment(content)
+ return "<!-- #{content} -->\n"
+ end
+
+ def self.tag(type, contents = '', &block)
+ out = nil
+
+ if block_given?
+ out = IndentedString.new
+ out << "<#{type}>"
+ out.raise_indent
+
+ out << block.call
+
+ out.lower_indent
+ out << "</#{type}>"
+ else
+ out = "<#{type}>#{contents.to_s}</#{type}>\n"
+ end
+
+ return out.to_s
+ end
+
+ def self.wrap(contents)
+ output = ''
+
+ output << '<?xml version="1.0" encoding="UTF-8"?>' + "\n"
+ output << '<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' + "\n"
+ output << '<plist version="1.0">' + "\n"
+
+ output << contents
+
+ output << '</plist>' + "\n"
+
+ return output
+ end
+
+ def self.element_type(item)
+ case item
+ when String, Symbol
+ 'string'
+
+ when Fixnum, Bignum, Integer
+ 'integer'
+
+ when Float
+ 'real'
+
+ else
+ raise "Don't know about this data type... something must be wrong!"
+ end
+ end
+ private
+ class IndentedString #:nodoc:
+ attr_accessor :indent_string
+
+ def initialize(str = "\t")
+ @indent_string = str
+ @contents = ''
+ @indent_level = 0
+ end
+
+ def to_s
+ return @contents
+ end
+
+ def raise_indent
+ @indent_level += 1
+ end
+
+ def lower_indent
+ @indent_level -= 1 if @indent_level > 0
+ end
+
+ def <<(val)
+ if val.is_a? Array
+ val.each do |f|
+ self << f
+ end
+ else
+ # if it's already indented, don't bother indenting further
+ unless val =~ /\A#{@indent_string}/
+ indent = @indent_string * @indent_level
+
+ @contents << val.gsub(/^/, indent)
+ else
+ @contents << val
+ end
+
+ # it already has a newline, don't add another
+ @contents << "\n" unless val =~ /\n$/
+ end
+ end
+ end
+end
+
+# we need to add this so sorting hash keys works properly
+class Symbol #:nodoc:
+ def <=> (other)
+ self.to_s <=> other.to_s
+ end
+end
+
+class Array #:nodoc:
+ include Plist::Emit
+end
+
+class Hash #:nodoc:
+ include Plist::Emit
+end
diff --git a/plist/parser.rb b/plist/parser.rb
new file mode 100755
index 0000000..6daa5bf
--- /dev/null
+++ b/plist/parser.rb
@@ -0,0 +1,225 @@
+#!/usr/bin/env ruby
+#
+# = plist
+#
+# Copyright 2006-2010 Ben Bleything and Patrick May
+# Distributed under the MIT License
+#
+
+# Plist parses Mac OS X xml property list files into ruby data structures.
+#
+# === Load a plist file
+# This is the main point of the library:
+#
+# r = Plist::parse_xml( filename_or_xml )
+module Plist
+# Note that I don't use these two elements much:
+#
+# + Date elements are returned as DateTime objects.
+# + Data elements are implemented as Tempfiles
+#
+# Plist::parse_xml will blow up if it encounters a data element.
+# If you encounter such an error, or if you have a Date element which
+# can't be parsed into a Time object, please send your plist file to
+# plist@hexane.org so that I can implement the proper support.
+ def Plist::parse_xml( filename_or_xml )
+ listener = Listener.new
+ #parser = REXML::Parsers::StreamParser.new(File.new(filename), listener)
+ parser = StreamParser.new(filename_or_xml, listener)
+ parser.parse
+ listener.result
+ end
+
+ class Listener
+ #include REXML::StreamListener
+
+ attr_accessor :result, :open
+
+ def initialize
+ @result = nil
+ @open = Array.new
+ end
+
+
+ def tag_start(name, attributes)
+ @open.push PTag::mappings[name].new
+ end
+
+ def text( contents )
+ @open.last.text = contents if @open.last
+ end
+
+ def tag_end(name)
+ last = @open.pop
+ if @open.empty?
+ @result = last.to_ruby
+ else
+ @open.last.children.push last
+ end
+ end
+ end
+
+ class StreamParser
+ def initialize( plist_data_or_file, listener )
+ if plist_data_or_file.respond_to? :read
+ @xml = plist_data_or_file.read
+ elsif File.exists? plist_data_or_file
+ @xml = File.read( plist_data_or_file )
+ else
+ @xml = plist_data_or_file
+ end
+
+ @listener = listener
+ end
+
+ TEXT = /([^<]+)/
+ XMLDECL_PATTERN = /<\?xml\s+(.*?)\?>*/um
+ DOCTYPE_PATTERN = /\s*<!DOCTYPE\s+(.*?)(\[|>)/um
+ COMMENT_START = /\A<!--/u
+ COMMENT_END = /.*?-->/um
+
+
+ def parse
+ plist_tags = PTag::mappings.keys.join('|')
+ start_tag = /<(#{plist_tags})([^>]*)>/i
+ end_tag = /<\/(#{plist_tags})[^>]*>/i
+
+ require 'strscan'
+
+ @scanner = StringScanner.new( @xml )
+ until @scanner.eos?
+ if @scanner.scan(COMMENT_START)
+ @scanner.scan(COMMENT_END)
+ elsif @scanner.scan(XMLDECL_PATTERN)
+ elsif @scanner.scan(DOCTYPE_PATTERN)
+ elsif @scanner.scan(start_tag)
+ @listener.tag_start(@scanner[1], nil)
+ if (@scanner[2] =~ /\/$/)
+ @listener.tag_end(@scanner[1])
+ end
+ elsif @scanner.scan(TEXT)
+ @listener.text(@scanner[1])
+ elsif @scanner.scan(end_tag)
+ @listener.tag_end(@scanner[1])
+ else
+ raise "Unimplemented element"
+ end
+ end
+ end
+ end
+
+ class PTag
+ @@mappings = { }
+ def PTag::mappings
+ @@mappings
+ end
+
+ def PTag::inherited( sub_class )
+ key = sub_class.to_s.downcase
+ key.gsub!(/^plist::/, '' )
+ key.gsub!(/^p/, '') unless key == "plist"
+
+ @@mappings[key] = sub_class
+ end
+
+ attr_accessor :text, :children
+ def initialize
+ @children = Array.new
+ end
+
+ def to_ruby
+ raise "Unimplemented: " + self.class.to_s + "#to_ruby on #{self.inspect}"
+ end
+ end
+
+ class PList < PTag
+ def to_ruby
+ children.first.to_ruby if children.first
+ end
+ end
+
+ class PDict < PTag
+ def to_ruby
+ dict = Hash.new
+ key = nil
+
+ children.each do |c|
+ if key.nil?
+ key = c.to_ruby
+ else
+ dict[key] = c.to_ruby
+ key = nil
+ end
+ end
+
+ dict
+ end
+ end
+
+ class PKey < PTag
+ def to_ruby
+ CGI::unescapeHTML(text || '')
+ end
+ end
+
+ class PString < PTag
+ def to_ruby
+ CGI::unescapeHTML(text || '')
+ end
+ end
+
+ class PArray < PTag
+ def to_ruby
+ children.collect do |c|
+ c.to_ruby
+ end
+ end
+ end
+
+ class PInteger < PTag
+ def to_ruby
+ text.to_i
+ end
+ end
+
+ class PTrue < PTag
+ def to_ruby
+ true
+ end
+ end
+
+ class PFalse < PTag
+ def to_ruby
+ false
+ end
+ end
+
+ class PReal < PTag
+ def to_ruby
+ text.to_f
+ end
+ end
+
+ require 'date'
+ class PDate < PTag
+ def to_ruby
+ DateTime.parse(text)
+ end
+ end
+
+ require 'base64'
+ class PData < PTag
+ def to_ruby
+ data = Base64.decode64(text.gsub(/\s+/, ''))
+
+ begin
+ return Marshal.load(data)
+ rescue Exception => e
+ io = StringIO.new
+ io.write data
+ io.rewind
+ return io
+ end
+ end
+ end
+end
diff --git a/public/images/arrow-270.png b/public/images/arrow-270.png
new file mode 100755
index 0000000..8d5209b
--- /dev/null
+++ b/public/images/arrow-270.png
Binary files differ
diff --git a/public/images/bg.png b/public/images/bg.png
new file mode 100755
index 0000000..8614c85
--- /dev/null
+++ b/public/images/bg.png
Binary files differ
diff --git a/public/images/bg_sub.png b/public/images/bg_sub.png
new file mode 100755
index 0000000..05e39fc
--- /dev/null
+++ b/public/images/bg_sub.png
Binary files differ
diff --git a/public/images/information.png b/public/images/information.png
new file mode 100755
index 0000000..fa9a60b
--- /dev/null
+++ b/public/images/information.png
Binary files differ
diff --git a/views/index.erb b/views/index.erb
new file mode 100644
index 0000000..fbe564f
--- /dev/null
+++ b/views/index.erb
@@ -0,0 +1,88 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
+ <script type="text/javascript" src="http://code.jquery.com/jquery-1.7.1.min.js"></script>
+ <style type="text/css">
+ #header {
+ width:100%;
+ background:url(/images/bg.png) repeat-x;
+ height:36px;
+ font-size:1.4em;
+ color:#fff;
+ border-bottom: 1px solid #444;
+ }
+ #header span {
+ line-height: 2.2em;
+ margin: 10px;
+ }
+ td {
+ padding: 5px;
+ }
+ th {
+ background: url(/images/bg_sub.png) repeat-x;
+ height: 20px;
+ color: #000;
+ font-weight: normal;
+ border-bottom: 1px solid #888;
+ border-top: 1px solid #fff;
+ text-align: left;
+ }
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ }
+ body {
+ padding: 0; margin: 0;
+ font: 12px/18px "Lucida Grande", "Lucida Sans Unicode", Arial, Verdana, sans-serif;
+ background-color: #fff;
+ color: #333;
+ }
+ .selected {
+ background-color: #3875D7 !important;
+ color: #fff;
+ }
+ .even {
+ background-color: #F3F6FA;
+ }
+ </style>
+ <script type="text/javascript">
+ $(function() {
+ $("tbody tr:even").addClass("even");
+ $("tbody tr").click(function(event) {
+ $("tbody tr").removeClass("selected");
+ $(this).addClass("selected");
+ });
+ });
+ </script>
+
+ <title><%= @@title %></title>
+</head>
+
+<body>
+ <div id="header"><span><%= @@title %></span></div>
+<table>
+<thead>
+ <tr>
+ <th>Name</th>
+ <th>Version</th>
+ <th>Size</th>
+ <th>Post Date</th>
+ <th></th>
+ <th></th>
+ </tr>
+</thead>
+<tbody>
+ <% @@products.each do |p| %>
+ <tr>
+ <td><%= p['title'] %></td>
+ <td><%= p['version'] %></td>
+ <td><%= p['size'] %> MB</td>
+ <td><%= p['date'] %></td>
+ <td><a href="<%= p['url'] %>"><img src="/images/arrow-270.png" title="Download" alt="Download"/></a></td>
+ <td><a href="<%= p['mdurl'] %>"><img src="/images/information.png" title="Get Info" alt="Get Info"/></a></td>
+ </tr>
+<% end %>
+</tbody>
+</body>
+</html>