diff --git a/.gitignore b/.gitignore index 80e3957..f7c1989 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ Gemfile.lock +.DS_Store diff --git a/Gemfile b/Gemfile index 55e8d37..3047bc1 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,6 @@ source :rubygems +gemspec -gem 'qtbindings' require 'rbconfig' case RbConfig::CONFIG['host_os'] @@ -10,5 +10,3 @@ when /linux/ gem 'rb-inotify' end -gem 'atomic' -gem 'thor' diff --git a/bin/unison-watch b/bin/unison-watch index 909ee8f..a199b2b 100755 --- a/bin/unison-watch +++ b/bin/unison-watch @@ -1,21 +1,36 @@ #!/usr/bin/env ruby +$: << File.expand_path('../../lib', __FILE__) + require 'Qt4' require 'thread' require 'atomic' require 'thor' require 'unison/profile' +require 'unison/config' +require 'unison/bridge' +require 'unison/filesystem_watcher' +require 'unison/ui/icon' +require 'unison/ui/menu' +require 'unison/ui/fileview' +require 'unison/ui/preferences' + +require 'forwardable' class UnisonWatcher < Qt::Application TRANSFER_LOG = '~/unison.log' SYNC_CHECK_COUNT = 600 SYNC_CHECK_TIME = 0.1 + extend Forwardable + + def_delegators :@config, :profiles + def initialize(profiles, *args) super(*args) - @profiles = profiles + @config = Unison::Config.ensure(File.expand_path('~/.unison/watch.yml')) @queue = Atomic.new([]) @sync_now = false @@ -28,130 +43,50 @@ class UnisonWatcher < Qt::Application end def processed_profiles - @processed_profiles ||= Unison::Profile.process(@profiles) + @processed_profiles ||= Unison::Profile.process(profiles) end def watch - require 'rbconfig' - - watcher = Thread.new do - while !Thread.current[:app]; sleep 0.1; end - - begin - @watch = nil - - case RbConfig::CONFIG['host_os'] - when /darwin/ - require 'rb-fsevent' - @watch = FSEvent.new - @watch.watch Thread.current[:paths], :latency => 0.1 do |directories| - Thread.current[:app] << directories - end - when /linux/ - require 'rb-inotify' - @watch = INotify::Notifier.new - Thread.current[:paths].each do |path| - FileUtils.mkdir_p path - - @watch.watch path, :recursive, :modify, :create, :delete do |event| - Thread.current[:app] << event.absolute_name - end - end - end - - @watch.run - rescue => e - puts e.message - puts e.backtrace.join("\n") - exit - end - end - - watcher[:paths] = processed_profiles.collect(&:paths_with_local_root).flatten - watcher[:app] = self + Unison::FilesystemWatcher.new(paths_to_watch, self) end - def update_status(status) - new_status = Qt::Action.new(status, @menu) - new_status.enabled = false - @menu.insertAction(@active_status, new_status) - @menu.removeAction(@status) + def paths_to_watch + processed_profiles.collect(&:paths_with_local_root).flatten + end - @status = new_status + def fileview + @fileview ||= Unison::UI::FileView.new(File.expand_path(TRANSFER_LOG)) end def menu - @menu = Qt::Menu.new + return @menu if @menu - @current_text = 'Unison watch idle.' + @menu = Unison::UI::Menu.new - @status = Qt::Action.new(@status_text, @menu) - @status.enabled = false - - sync_now = Qt::Action.new("Sync now", @menu) - sync_now.connect(SIGNAL :triggered) { @sync_now = true } - - @active_status = Qt::Action.new("Pause syncing", @menu) - @active_status.connect(SIGNAL :triggered) { toggle_status } - - @current_log_size = File.size(File.expand_path(TRANSFER_LOG)) - - @fileview = Qt::TextEdit.new - @fileview.plainText = "Watching for changes..." - @fileview.readOnly = true - @fileview.resize 800, 600 - @fileview.connect(SIGNAL :textChanged) { @fileview.moveCursor Qt::TextCursor::End } - - @log = Qt::Action.new("View transfer log", @menu) - @log.connect(SIGNAL :triggered) { @fileview.show } - - quit = Qt::Action.new("Quit", @menu) - quit.connect(SIGNAL :triggered) { @exiting = true } - - @menu.addAction @active_status - @menu.addAction sync_now - @menu.addSeparator - @menu.addAction @log - @menu.addAction quit - - @current_text = 'Unison watch idle.' - update_status @current_text + @menu.on(:sync_now) { @sync_now = true } + @menu.on(:toggle_status) { toggle_status } + @menu.on(:view_log) { fileview.show } + @menu.on(:quit) { @exiting = true } + @menu.on(:preferences) { preferences.show } + @menu.generate @menu end def update_ui - if @current_icon != @prior_icon - if !@icons[@current_icon] - @icons[@current_icon] = Qt::Icon.new(File.expand_path("../../assets/#{@current_icon}.png", __FILE__)) - @icons["large-#{@current_icon}"] = Qt::Icon.new(File.expand_path("../../assets/large-#{@current_icon}.png", __FILE__)) - end - - @icon.icon = @icons[@current_icon] - self.windowIcon = @icons["large-#{@current_icon}"] - @icon.toolTip = "Unison Agent\nUsing #{@profiles.join(', ')} profile" - if !@prior_icon - @icon.show - end - - @prior_icon = @current_icon - end - - if @current_text!= @prior_text - update_status @current_text - - @prior_text = @current_text - end + @icon.current_icon = @current_icon + @menu.status_text = @current_text processEvents end def ui - @icon = Qt::SystemTrayIcon.new - @icon.contextMenu = menu + @icon = Unison::UI::Icon.new(menu, self, profiles, File.expand_path('../../assets', __FILE__)) + @config.on_update { @icon.profiles = @config.profiles } + + @current_icon = 'idle' toggle_status true - @prior_icon = nil @prior_text = nil @icons = {} @@ -168,12 +103,36 @@ class UnisonWatcher < Qt::Application end def start - self.objectName = "cats" + if !@config.active_profiles? + preferences.show + end watch ui end + def preferences + @preferences ||= Unison::UI::Preferences.new(@config) + end + + def show_working + index = 0 + while !@done + @current_icon = "working-#{index + 1}" + fileview.read! + + update_ui + + break if @done + + sleep 0.25 + index = (index + 1) % 2 + end + + @current_icon = 'idle' + fileview.read! + end + def check begin if @active && (@queue.value.length > 0 || @remote_sync_check == 0 || @sync_now) @@ -183,39 +142,13 @@ class UnisonWatcher < Qt::Application @done = false - runner = Thread.new do - @profiles.each do |profile| - system %{bash -c 'unison -log -logfile #{TRANSFER_LOG} -batch #{profile}'} - end - @done = true - end + Unison::Bridge.run(profiles, TRANSFER_LOG) { @done = true } - index = 0 - while !@done - @current_icon = "working-#{index + 1}" - File.open(File.expand_path(TRANSFER_LOG), 'r') { |fh| - fh.seek(@current_log_size) - @fileview.plainText = fh.read - } - - update_ui - - break if @done - - sleep 0.25 - index = (index + 1) % 2 - end - - @current_icon = 'idle' + show_working @remote_sync_check = SYNC_CHECK_COUNT @sync_now = false @queue.update { [] } - - File.open(File.expand_path(TRANSFER_LOG), 'r') { |fh| - fh.seek(@current_log_size) - @fileview.plainText = fh.read - } end rescue => e puts e.message @@ -237,23 +170,51 @@ class UnisonWatcher < Qt::Application def toggle_status(set = nil) @active = set || !@active - @active_status.text = @active ? "Pause syncing" : "Resume syncing" + @menu.active_status_text = @active ? "Pause syncing" : "Resume syncing" @current_icon = @active ? 'idle' : 'paused' end end -class UnisonCLI < Thor - desc 'start profile ...', 'Run Unison Watch using the provided profiles' - def start(*profiles) - UnisonWatcher.new(profiles, ARGV).start - end +module Unison + class CLI < Thor + include Thor::Actions - default_task :run + desc 'start profile ...', 'Run Unison Watch using the provided profiles' + def start(*profiles) + UnisonWatcher.new(profiles, ARGV).start + end - def method_missing(*args) - start(*args) + default_task :run + + def method_missing(*args) + start(*args) + end + + def self.source_root + File.expand_path('../../skel', __FILE__) + end + + desc 'app-bundle', 'Make an app bundle in the current directory' + def app_bundle + destination_path = Dir.pwd + + directory 'UnisonWatch.app', 'UnisonWatch.app' + Dir['UnisonWatch.app/**/*'].each do |file| + if File.directory?(file) + File.chmod(0755, file) + end + end + + File.chmod(0755, "UnisonWatch.app/Contents/MacOS/UnisonWatch") + end + + no_tasks do + def gem_directory + File.expand_path('../..', __FILE__) + end + end end end -UnisonCLI.start +Unison::CLI.start diff --git a/lib/unison/bridge.rb b/lib/unison/bridge.rb new file mode 100644 index 0000000..16a1959 --- /dev/null +++ b/lib/unison/bridge.rb @@ -0,0 +1,28 @@ +module Unison + class Bridge + def self.run(*args, &block) + new(*args).run(&block) + end + + def initialize(profiles, log) + @profiles, @log = profiles, log + end + + def run(&block) + Thread.new do + begin + @profiles.each do |profile| + system %{bash -c 'unison -log -logfile #{@log} -batch #{profile} 2>>#{@log.stderr} >>#{@log.stdout}'} + end + + block.call + rescue => e + puts e.message + puts e.backtrace.join("\n") + raise e + end + end + end + end +end + diff --git a/lib/unison/config.rb b/lib/unison/config.rb new file mode 100644 index 0000000..0a29b35 --- /dev/null +++ b/lib/unison/config.rb @@ -0,0 +1,70 @@ +require 'yaml' +require 'atomic' + +module Unison + class Config + def self.ensure(file) + if !File.file?(file) + File.open(file, 'wb') { |fh| fh.print YAML.dump(skel_data) } + end + + new(file) + end + + def self.skel_data + { 'profiles' => [] } + end + + def set_profile(profile, is_set) + @data.update do |d| + if is_set + d['profiles'] << profile + else + d['profiles'].delete(profile) + end + + d['profiles'].uniq! + + d + end + + @on_update.call if @on_update + + save + end + + def on_update(&block) + @on_update = block + end + + def initialize(file) + @file = file + + @data = Atomic.new(nil) + end + + def active?(profile) + profiles.include?(profile) + end + + def profiles + data['profiles'] + end + + def data + @data.update { |d| d || YAML.load_file(@file) } + @data.value + end + + def active_profiles? + !profiles.empty? + end + + def save + @data.update do |d| + File.open(@file, 'wb') { |fh| fh.print YAML.dump(d) } + d + end + end + end +end diff --git a/lib/unison/filesystem_watcher.rb b/lib/unison/filesystem_watcher.rb new file mode 100644 index 0000000..8e71b82 --- /dev/null +++ b/lib/unison/filesystem_watcher.rb @@ -0,0 +1,58 @@ +module Unison + class FilesystemWatcher + class NoWatcherAvailable < StandardError ; end + + def initialize(paths, owner) + @paths, @owner = paths, owner + end + + def run + require 'rbconfig' + + @watcher = Thread.new do + while !Thread.current[:app]; sleep 0.1; end + + begin + case RbConfig::CONFIG['host_os'] + when /(darwin|linux)/ + @watch = send("watcher_for_#{$1}") + else + raise NoWatcherAvailable.new + end + + @watch.run + rescue => e + puts e.message + puts e.backtrace.join("\n") + exit + end + end + + @watcher[:paths] = @paths + @watcher[:app] = @owner + end + + def watcher_for_darwin + require 'rb-fsevent' + watch = FSEvent.new + watch.watch Thread.current[:paths], :latency => 1.0 do |directories| + Thread.current[:app] << directories + end + watch + end + + def watcher_for_linux + require 'rb-inotify' + watch = INotify::Notifier.new + Thread.current[:paths].each do |path| + FileUtils.mkdir_p path + + watch.watch path, :recursive, :modify, :create, :delete do |event| + Thread.current[:app] << event.absolute_name + end + end + watch + end + end +end + diff --git a/lib/unison/profile.rb b/lib/unison/profile.rb index 83ca068..41c3546 100644 --- a/lib/unison/profile.rb +++ b/lib/unison/profile.rb @@ -1,9 +1,15 @@ module Unison class Profile + PROFILE_DIR = File.expand_path('~/.unison') + def self.process(profiles) profiles.collect { |profile| new(profile) } end + def self.available + Dir[File.join(PROFILE_DIR, '*.prf')].collect { |file| File.basename(file).gsub('.prf', '') }.sort + end + def initialize(which) @which = which end diff --git a/lib/unison/ui/fileview.rb b/lib/unison/ui/fileview.rb new file mode 100644 index 0000000..37e34ef --- /dev/null +++ b/lib/unison/ui/fileview.rb @@ -0,0 +1,31 @@ +module Unison + module UI + class FileView < Qt::TextEdit + INITIAL = "Watching for changes..." + + def initialize(file, *args) + super(*args) + + @file = file + @current_log_size = File.size(@file) + + self.plainText = INITIAL + self.readOnly = true + self.resize 800, 600 + self.connect(SIGNAL(:textChanged), &method(:on_text_change)) + end + + def on_text_change + self.moveCursor Qt::TextCursor::End + end + + def read! + File.open(@file, 'r') { |fh| + fh.seek(@current_log_size) + self.plainText = fh.read + } + end + end + end +end + diff --git a/lib/unison/ui/icon.rb b/lib/unison/ui/icon.rb new file mode 100644 index 0000000..280cfcc --- /dev/null +++ b/lib/unison/ui/icon.rb @@ -0,0 +1,45 @@ +module Unison + module UI + class Icon < Qt::SystemTrayIcon + def initialize(menu, window, profiles, icon_source, *args) + super(*args) + + self.contextMenu = menu + @window, @icon_source, @profiles = window, icon_source, profiles + + @current_icon = nil + @icons = {} + end + + def qt_icon_for(name) + @icons[name] ||= Qt::Icon.new(File.join(@icon_source, "#{name}.png")) + end + + def large_qt_icon_for(name) + qt_icon_for("large-#{name}") + end + + def profiles=(profiles) + @profiles = profiles + set_tooltip + end + + def current_icon=(icon) + if icon != @current_icon + self.icon = qt_icon_for(icon) + @window.windowIcon = large_qt_icon_for(icon) + set_tooltip + show if !@current_icon + + @current_icon = icon + end + end + + def set_tooltip + self.toolTip = "Unison Agent\nUsing #{@profiles.join(', ')} profile" + show + end + end + end +end + diff --git a/lib/unison/ui/menu.rb b/lib/unison/ui/menu.rb new file mode 100644 index 0000000..451ee24 --- /dev/null +++ b/lib/unison/ui/menu.rb @@ -0,0 +1,70 @@ +module Unison + module UI + class Menu < Qt::Menu + IDLE = 'Unison watch idle.' + SYNC_NOW = 'Sync now' + PAUSE_SYNCING = 'Pause syncing' + VIEW_TRANSFER_LOG = "View transfer log" + QUIT = 'Quit' + + def initialize(*args) + super(*args) + + @status_text = IDLE + end + + def on(event, &block) + @on ||= {} + @on[event] = block + end + + def generate + generate_status + + sync_now = Qt::Action.new(SYNC_NOW, self) + sync_now.connect(SIGNAL(:triggered), &@on[:sync_now]) + + @active_status = Qt::Action.new(PAUSE_SYNCING, self) + @active_status.connect(SIGNAL(:triggered), &@on[:toggle_status]) + + @log = Qt::Action.new(VIEW_TRANSFER_LOG, @menu) + @log.connect(SIGNAL(:triggered), &@on[:view_log]) + + @preferences = Qt::Action.new('Preferences...', @menu) + @preferences.connect(SIGNAL(:triggered), &@on[:preferences]) + + quit = Qt::Action.new(QUIT, @menu) + quit.connect(SIGNAL(:triggered), &@on[:quit]) + + addAction @active_status + addAction sync_now + addSeparator + addAction @log + addAction @preferences + addAction quit + end + + def status_text=(text) + if @status_text != text + @status_text = text + + generate_status + end + end + + def active_status_text=(text) + @active_status.text = text + end + + def generate_status + new_status = Qt::Action.new(@status_text, self) + new_status.enabled = false + insertAction(@active_status, new_status) + removeAction(@status) + + @status = new_status + end + end + end +end + diff --git a/lib/unison/ui/preferences.rb b/lib/unison/ui/preferences.rb new file mode 100644 index 0000000..785fecb --- /dev/null +++ b/lib/unison/ui/preferences.rb @@ -0,0 +1,32 @@ +module Unison + module UI + class Preferences < Qt::Widget + def initialize(config, *args) + super(*args) + + @config = config + + generate + end + + def generate + layout = Qt::GridLayout.new + + profile_group = Qt::GroupBox.new("Profiles") + profile_group_layout = Qt::VBoxLayout.new + profile_group.setLayout(profile_group_layout) + + Unison::Profile.available.each do |profile| + radio = Qt::CheckBox.new(profile) + radio.checked = @config.active?(profile) + radio.connect(SIGNAL "toggled(bool)") { |checked| @config.set_profile(profile, checked) } + profile_group_layout.addWidget(radio) + end + + layout.addWidget(profile_group) + + setLayout(layout) + end + end + end +end diff --git a/skel/UnisonWatch.app/Contents/Info.plist b/skel/UnisonWatch.app/Contents/Info.plist new file mode 100644 index 0000000..d7f7127 --- /dev/null +++ b/skel/UnisonWatch.app/Contents/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleExecutable + UnisonWatch + CFBundleGetInfoString + Unison Watch + CFBundleIdentifier + webapps.webrunner + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + UnisonWatch + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.3.0 + CFBundleSignature + WRUN + CFBundleVersion + 0.0.1.20070616 + + diff --git a/skel/UnisonWatch.app/Contents/MacOS/UnisonWatch.tt b/skel/UnisonWatch.app/Contents/MacOS/UnisonWatch.tt new file mode 100755 index 0000000..ce5341f --- /dev/null +++ b/skel/UnisonWatch.app/Contents/MacOS/UnisonWatch.tt @@ -0,0 +1,5 @@ +#!/bin/bash + +cd <%= gem_directory %> +GEM_HOME=<%= ENV['GEM_HOME'] %> GEM_PATH=<%= ENV['GEM_PATH'] %> PATH=<%= `which ruby`.gsub(%r{/[^/]+$}, '') %>:$PATH bin/unison-watch + diff --git a/skel/UnisonWatch.app/Contents/PkgInfo b/skel/UnisonWatch.app/Contents/PkgInfo new file mode 100644 index 0000000..9d5b594 --- /dev/null +++ b/skel/UnisonWatch.app/Contents/PkgInfo @@ -0,0 +1 @@ +APPLWRUN diff --git a/unison-watch.gemfile b/unison-watch.gemfile deleted file mode 100644 index e69de29..0000000 diff --git a/unison-watch.gemspec b/unison-watch.gemspec new file mode 100644 index 0000000..852f79a --- /dev/null +++ b/unison-watch.gemspec @@ -0,0 +1,19 @@ +Gem::Specification.new do |gem| + gem.authors = ["John Bintz"] + gem.email = ["john@coswellproductions.com"] + gem.description = %q{No-nonsense JavaScript testing solution.} + gem.summary = %q{No-nonsense JavaScript testing solution.} + gem.homepage = "" + + gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } + gem.files = `git ls-files`.split("\n") + gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") + gem.name = "unison-watch" + gem.require_paths = ["lib"] + gem.version = '0.0.1' + + gem.add_dependency 'qtbindings' + gem.add_dependency 'thor' + gem.add_dependency 'atomic' +end +