From 5a0d1d5556e3f963a0d34da46e16ecffa59ea2fc Mon Sep 17 00:00:00 2001 From: phinze Date: Sun, 4 Mar 2012 20:46:30 -0600 Subject: [PATCH] quick sketchy first release of code/ideas this is a first draft of an idea i've had kicking around for awhile pushing out some code so i can get a conversation started --- Casks/alfred.rb | 5 + Casks/dropbox.rb | 5 + Casks/google-chrome.rb | 5 + Casks/keepass-x.rb | 5 + Casks/nv-alt.rb | 5 + README.md | 101 ++++++++++++++++++ bin/brew-cask.rb | 4 + lib/cask.rb | 162 ++++++++++++++++++++++++++++ lib/cask/cli.rb | 52 +++++++++ lib/cask/cli/install.rb | 11 ++ lib/cask/cli/linkapps.rb | 9 ++ lib/cask/cli/list.rb | 9 ++ lib/cask/cli/search.rb | 10 ++ lib/plist/parser.rb | 225 +++++++++++++++++++++++++++++++++++++++ 14 files changed, 608 insertions(+) create mode 100644 Casks/alfred.rb create mode 100644 Casks/dropbox.rb create mode 100644 Casks/google-chrome.rb create mode 100644 Casks/keepass-x.rb create mode 100644 Casks/nv-alt.rb create mode 100644 README.md create mode 100755 bin/brew-cask.rb create mode 100644 lib/cask.rb create mode 100644 lib/cask/cli.rb create mode 100644 lib/cask/cli/install.rb create mode 100644 lib/cask/cli/linkapps.rb create mode 100644 lib/cask/cli/list.rb create mode 100644 lib/cask/cli/search.rb create mode 100644 lib/plist/parser.rb diff --git a/Casks/alfred.rb b/Casks/alfred.rb new file mode 100644 index 000000000..2d075f8dd --- /dev/null +++ b/Casks/alfred.rb @@ -0,0 +1,5 @@ +class Alfred < Cask + url 'http://rwc.cachefly.net/alfred_1.1_189.dmg' + homepage 'http://www.alfredapp.com/' + version '1.1_189' +end diff --git a/Casks/dropbox.rb b/Casks/dropbox.rb new file mode 100644 index 000000000..743244db2 --- /dev/null +++ b/Casks/dropbox.rb @@ -0,0 +1,5 @@ +class Dropbox < Cask + url 'http://dl-web.dropbox.com/u/17/Dropbox%201.2.52.dmg' + homepage 'http://www.dropbox.com/' + version '1.2.52' +end diff --git a/Casks/google-chrome.rb b/Casks/google-chrome.rb new file mode 100644 index 000000000..0ef4cc320 --- /dev/null +++ b/Casks/google-chrome.rb @@ -0,0 +1,5 @@ +class GoogleChrome < Cask + url 'https://dl.google.com/chrome/mac/stable/GGRO/googlechrome.dmg' + homepage 'https://www.google.com/chrome/' + version '17.0.963.56' +end diff --git a/Casks/keepass-x.rb b/Casks/keepass-x.rb new file mode 100644 index 000000000..fc567d9f1 --- /dev/null +++ b/Casks/keepass-x.rb @@ -0,0 +1,5 @@ +class KeepassX < Cask + url 'http://downloads.sourceforge.net/keepassx/KeePassX-0.4.3.dmg' + homepage 'http://www.keepassx.org' + version '0.4.3' +end diff --git a/Casks/nv-alt.rb b/Casks/nv-alt.rb new file mode 100644 index 000000000..9db252ea5 --- /dev/null +++ b/Casks/nv-alt.rb @@ -0,0 +1,5 @@ +class NvAlt < Cask + url 'http://brettterpstra.com/downloads/nvalt2.1.zip?9d7bd4' + homepage 'http://brettterpstra.com/project/nvalt/' + version '2.1' +end diff --git a/README.md b/README.md new file mode 100644 index 000000000..9cbbb6baa --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# "To install, drag this icon..." no more! + +Let's see if we can get the elegance, simplicity, and speed of Homebrew for the +installation and management GUI Mac applications like Google Chrome and Adium. + +`brew-cask` provides a friendly homebrew-style CLI workflow for the +administration of Mac applications distributed as binaries. + +It's implemented as a `homebrew` "[external +command](https://github.com/mxcl/homebrew/wiki/External-Commands)" called +`cask`. + +# Let's try it! + +## Install and set up brew-cask + +This is still a little ornery. You'll probably want to also see "Known +Ugliness" below. + + # be sure you have Homebrew installed first + + $ git clone https://github.com/phinze/brew-cask + $ ln -s brew-cask/bin/brew-cask.rb ~/bin # or anywhere in your $PATH + $ ln -s brew-cask/Casks /usr/local/Library/ + +## Now let's install something + +Let's see if there's a Cask for Chrome: + + $ brew cask search chrome + google-chrome + +Cool, there it is. Let's install it. + + $ brew cask install google-chrome + Downloading... + Success! google-chrome installed to /usr/local/Cellar/google-chrome/17.0.963.56 + +Now we have `Google Chrome.app` in our Cellar, let's get it linked somewhere useful: + + $ brew cask linkapps + /Users/phinze/Applications/Google Chrome.app -> /usr/local/Cellar/google-chrome/17.0.963.56/Google Chrome.app + +And there we have it. Google Chrome installed with a few quick commands; no clicking, no dragging, no dropping. + + open "~/Applications/Google Chrome.app" + +# What is a Cask? + +A `Cask` is like a `Formula` in Homebrew except it describes how to download +and install a binary application. + +Casks have two important fields: + + * __url__: (required) points to binary distribution of the application + * __version__: (required) describes the version of the application available at the URL + +# What Casks are available? + +Just run `brew cask search` with no arguments to get a list. + +Here's the current list: + +
+alfred
+dropbox
+google-chrome
+keepass-x
+nv-alt
+
+ +# What's the status of this project? Where's it headed? + +It's really just a start at this point, but it works, and I've got big plans! + +`brew-cask` currently understands how to install `dmg` and `zip` files that +contain a `app` file. I'd like to extend it to be able to handle `pkg` files +as well as the numerous other permutations of compression and distribution in +the wild (`app` inside `dmg` inside `zip`; folder inside `dmg`; etc.). + +I plan to use the `Cask` model to allow per-project customization of behavior, +like Homebrew does with `Formula`. This would allow weirdo applications like +Eclipse ("really you want me to drag that whole folder to `Applications`?") to +contain their complexity. + +Each Cask will then encapsulate and automate the story of how a given +application should be installed. If all goes well - I'm hoping to build up a +community-maintained collection of Casks that becomes the standard way that +hackers install Mac apps. + +# Known Ugliness + +The interactions with Old Uncle Homebrew are a little funky at this point. I'm +still playing with sharing Homebrew's Cellar (which we do in the current +implementation). This means that `cask` applications show up in regular old +`brew list`, and can be `unlink`ed and `uninstall`ed by `brew`. But `cask` +apps are not regular formula, so they won't show up in `brew search` and `brew +info` will not return anything for you. + +So there's some coolness out of playing in @mxcl's playground, but also some +confusing behavior. We'll see how it plays out. diff --git a/bin/brew-cask.rb b/bin/brew-cask.rb new file mode 100755 index 000000000..e11f61a36 --- /dev/null +++ b/bin/brew-cask.rb @@ -0,0 +1,4 @@ +$LOAD_PATH << File.expand_path('../../lib', Pathname.new(__FILE__).realpath) +require 'cask' + +Cask::CLI.process(ARGV) diff --git a/lib/cask.rb b/lib/cask.rb new file mode 100644 index 000000000..3edf1866e --- /dev/null +++ b/lib/cask.rb @@ -0,0 +1,162 @@ +require 'download_strategy' +require 'plist/parser' +require 'uri' + +HOME_APPS = Pathname.new(File.expand_path("~/Applications")) + +class Cask; end + +require 'cask/cli' +require 'cask/cli/install' +require 'cask/cli/linkapps' +require 'cask/cli/list' +require 'cask/cli/search' +require 'plist/parser' + +class Cask + def self.path + HOMEBREW_PREFIX.join("Library", "Casks") + end + + def self.cellarpath + HOMEBREW_CELLAR + end + + def self.all + cask_titles = path.entries.map(&:to_s).grep(/.rb$/).map { |p| p.to_s.split('.').first } + cask_titles.map { |c| self.load(c) } + end + + def self.init + path.mkpath + HOMEBREW_CACHE.mkpath + HOME_APPS.mkpath + end + + def self.homepage(homepage=nil) + @homepage ||= homepage + end + def homepage; self.class.homepage; end + + def self.installed + self.all.select(&:installed?) + end + + def self.load(cask_title) + require path.join(cask_title) + const_get(cask_title.split('-').map(&:capitalize).join).new + end + + def self.title + self.name.gsub(/([a-z\d])([A-Z])/,'\1-\2').downcase + end + + def self.url(url=nil) + @url ||= URI.parse(url) + end + def url; self.class.url; end + + attr_reader :title + def initialize(title=self.class.title) + @title = title + end + + def self.version(version=nil) + @version ||= version + end + def version; self.class.version; end + # def version + # Pathname.new(self.url.path.to_s).version + # end + + VALID_SUFFIXES = ['dmg', 'pkg', 'app'] + + def destination_path + HOMEBREW_CELLAR.join(self.title).join(self.version) + end + + def install + downloader = CurlDownloadStrategy.new(self.url.to_s, self.title, self.version, {}) + downloaded_path = downloader.fetch + + FileUtils.mkdir_p destination_path + + _with_extracted_mountpoints(downloaded_path) do |mountpoint| + puts `ditto #{mountpoint} #{destination_path}` + end + + puts "Success! #{self} installed to #{destination_path}" + end + + def linkapps + destination_path.entries.select { |f| f.basename.to_s =~ /.app$/ }.each do |app| + symlink_destination = HOME_APPS.join(app.basename) + symlink_target = destination_path.join(app) + if symlink_destination.directory? || symlink_destination.file? + puts "#{symlink_destination} already exists and is not a symlink, not linking #{self}" + elsif symlink_destination.symlink? + puts "#{symlink_destination} exists but is symlink; removing and relinking" + puts "#{symlink_destination} -> #{symlink_target}" + symlink_destination.delete + symlink_destination.make_symlink(symlink_destination) + else + puts "#{symlink_destination} -> #{symlink_target}" + symlink_destination.make_symlink(symlink_destination) + end + end + end + + def installed? + return false unless destination_path.exist? + destination_path.entries.any? do |f| + f.basename.to_s =~ /.app$/ + end + end + + def _with_extracted_mountpoints(path) + if _dmg?(path) + File.open(path) do |dmg| + xml_str = `hdiutil mount -plist -nobrowse -readonly -noidme -mountrandom /tmp #{dmg.path}` + hdiutil_info = Plist::parse_xml(xml_str) + raise Exception.new("No disk entities returned by mount at #{dmg.path}") unless hdiutil_info.has_key?("system-entities") + mounts = hdiutil_info["system-entities"].collect { |entity| + entity["mount-point"] + }.compact + begin + mounts.each do |mountpoint| + yield Pathname.new(mountpoint) + end + ensure + mounts.each do |mountpoint| + `hdiutil eject #{mountpoint}` + end + end + end + elsif _zip?(path) + destdir = "/tmp/brewcask_#{@title}_extracted" + `mkdir -p #{destdir}` + `unzip -d '#{destdir}' '#{path}'` + begin + yield destdir + ensure + `rm -rf '#{destdir}'` + end + else + raise "uh oh, could not identify type of #{path}" + end + end + + def _dmg?(path) + output = `hdiutil imageinfo #{path} 2>/dev/null` + output != '' + end + + def _zip?(path) + output = `file -Izb #{path}` + output.chomp == 'application/x-empty compressed-encoding=application/zip; charset=binary; charset=binary' + end + + def to_s + @title + end +end diff --git a/lib/cask/cli.rb b/lib/cask/cli.rb new file mode 100644 index 000000000..e75017eb0 --- /dev/null +++ b/lib/cask/cli.rb @@ -0,0 +1,52 @@ +class Cask::CLI + def self.commands + Cask::CLI.constants - ["NullCommand"] + end + + def self.lookup_command(command) + if command && Cask::CLI.const_defined?(command.capitalize) + Cask::CLI.const_get(command.capitalize) + else + Cask::CLI::NullCommand.new(command) + end + end + + def self.process(arguments) + Cask.init + command, *rest = *arguments + lookup_command(command).run(*rest) + end + + class NullCommand + def initialize(attempted_name) + @attempted_name = attempted_name + end + + def run(*args) + purpose + if @attempted_name + puts "!! " + puts "!! no command with name: #{@attempted_name}" + puts "!! " + end + usage + end + + def purpose + puts <<-PURPOSE.gsub(/^ {6}/, '') + {{ brew-cask }} + brew-cask provides a friendly homebrew-style CLI workflow for the + administration Mac applications distributed as binaries + PURPOSE + end + + def usage + puts "available commands: " + puts Cask::CLI.commands.map {|c| " - #{c.downcase}: #{_help_for(c)}"}.join("\n") + end + + def _help_for(command) + Cask::CLI.lookup_command(command).help + end + end +end diff --git a/lib/cask/cli/install.rb b/lib/cask/cli/install.rb new file mode 100644 index 000000000..0e7b580e9 --- /dev/null +++ b/lib/cask/cli/install.rb @@ -0,0 +1,11 @@ +class Cask::CLI::Install + def self.run(*arguments) + cask_name, *rest = *arguments + cask = Cask.load(cask_name) + cask.install + end + + def self.help + "installs the cask of the given name" + end +end diff --git a/lib/cask/cli/linkapps.rb b/lib/cask/cli/linkapps.rb new file mode 100644 index 000000000..90651cc72 --- /dev/null +++ b/lib/cask/cli/linkapps.rb @@ -0,0 +1,9 @@ +class Cask::CLI::Linkapps + def self.run(*arguments) + Cask.installed.map(&:linkapps) + end + + def self.help + "makes a symlink from all cask-installed .app files into ~/Applications" + end +end diff --git a/lib/cask/cli/list.rb b/lib/cask/cli/list.rb new file mode 100644 index 000000000..1ff12d90a --- /dev/null +++ b/lib/cask/cli/list.rb @@ -0,0 +1,9 @@ +class Cask::CLI::List + def self.run(*arguments) + puts Cask.installed.map(&:to_s).join("\n") + end + + def self.help + "lists installed casks" + end +end diff --git a/lib/cask/cli/search.rb b/lib/cask/cli/search.rb new file mode 100644 index 000000000..abced7a01 --- /dev/null +++ b/lib/cask/cli/search.rb @@ -0,0 +1,10 @@ +class Cask::CLI::Search + def self.run(*arguments) + search_term, *rest = *arguments + puts Cask.all.map(&:to_s).grep(/#{search_term}/).join("\n") + end + + def self.help + "searches all known casks" + end +end diff --git a/lib/plist/parser.rb b/lib/plist/parser.rb new file mode 100644 index 000000000..577e436b9 --- /dev/null +++ b/lib/plist/parser.rb @@ -0,0 +1,225 @@ +# +# = 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*)/um + COMMENT_START = /\A/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 + + require 'cgi' + 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