Last active
May 2, 2024 07:53
-
-
Save ttscoff/1f30e9042a4cfbdc68bc3fb3fec64046 to your computer and use it in GitHub Desktop.
Grab an iOS or Mac app icon from local version or iTunes search
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env ruby -W1 | |
# frozen_string_literal: true | |
# Mac only | |
# | |
# Usage: grabicon.rb SEARCH TERMS [%[small|medium|large] [@[mac|ios|iphone|ipad]] | |
# If the search terms match a local app, that app's icon will be extracted and converted to PNG | |
# If the search terms don't match a local app, iTunes will be searched | |
# If the search terms end with "@mac", "@iphone", "@ipad", or "@ios", iTunes will be searched for a match | |
# If the search terms end with "%large", "%medium", "%small", or "%XXX" icon will be scaled to size | |
# @ and % values can be specified by -p PLATFORM and -s SIZE | |
# -o PATH will output icon to specified path | |
# | |
# If an iOS app is retrieved from iTunes, its corners will be automatically rounded and padding will be | |
# added to the resulting image. | |
# | |
# Requires that ImageMagick be installed (brew install imagemagick) | |
%w[net/http cgi json fileutils optparse].each do |filename| | |
require filename | |
end | |
# Default size of icon | |
DEFAULT_SIZE = 512 | |
class ::String | |
def remove_parameters | |
gsub(/ *(@(mac|i(os|phone|pad))|%(s(m(all)?)?|m(ed(ium)?)?|l(g|arge)?|\d+))/, '').gsub(/\s+/, ' ').strip | |
end | |
end | |
# Icon retrieval | |
class GrabIcon | |
attr_reader :icon | |
def initialize(terms, options = {}) | |
@terms = terms | |
@options = { | |
outfile: nil, | |
platform: 'mac', | |
preview: false, | |
size: DEFAULT_SIZE | |
}.merge(options) | |
@icon = if terms =~ /@(ios|ipad|iphone)\b/i || @options[:platform] !~ /mac/i | |
find_itunes_icon | |
else | |
appdir = find_app_dir | |
if appdir | |
retrieve_local_icon(appdir) | |
else | |
find_itunes_icon | |
end | |
end | |
if @options[:outfile] | |
FileUtils.mv(@icon, @options[:outfile], force: true) | |
@icon = @options[:outfile] | |
end | |
end | |
def appdirs | |
[ | |
'/System/Applications/', | |
'/Developer/Applications/', | |
'/Developer/Applications/Utilities/', | |
'/Applications/Utilities/', | |
'/Applications/Setapp/', | |
'/Applications/' | |
] | |
end | |
def find_app_dir | |
appdir = nil | |
app = @terms.remove_parameters | |
appdirs.filter do |dir| | |
if File.exist?(File.join(dir, "#{app}.app")) | |
appdir = dir | |
elsif File.exist?(File.join(dir, "#{app}.localized/#{app}.app")) | |
appdir = File.join(dir, "#{app}.localized/") | |
end | |
end | |
appdir | |
end | |
def icon_size | |
case @terms | |
when /%l/ | |
512 | |
when /%m/ | |
100 | |
when /%s/ | |
60 | |
when /%(\d+)/ | |
Regexp.last_match(1).to_i | |
else | |
case @options[:size] | |
when /^l/ | |
512 | |
when /^m/ | |
100 | |
when /^s/ | |
60 | |
else | |
@options[:size] | |
end | |
end | |
end | |
def retrieve_local_icon(appdir) | |
app = @terms.remove_parameters | |
tempfile = false | |
icon = `defaults read "#{appdir}#{app}.app/Contents/Info" CFBundleIconName 2> /dev/null`.strip | |
if icon == '' | |
icon = `defaults read "#{appdir}#{app}.app/Contents/Info" CFBundleIconFile 2> /dev/null`.strip.sub(/\.icns$/, '') | |
icon = File.join(appdir, "#{app}.app", "Contents/Resources/#{icon}.icns") | |
else | |
tempfile = true | |
temp_icon = File.join(File.expand_path('~/Desktop'), "#{app}.icns") | |
`iconutil -c icns #{appdir}#{app}.app/Contents/Resources/Assets.car #{icon} -o #{temp_icon}` | |
icon = temp_icon | |
end | |
outfile = File.join(File.expand_path('~/Desktop'), "#{app}_icon.png") | |
cmd = [ | |
"/usr/bin/sips -s format png --resampleHeightWidthMax #{icon_size}", | |
%("#{icon}"), | |
%(--out "#{outfile}") | |
] | |
`#{cmd.join(' ')}` | |
warn "Wrote PNG to #{outfile}." | |
FileUtils.rm(icon) if tempfile | |
outfile | |
end | |
def define_entity | |
case @terms | |
when /@iphone/i | |
'software' | |
when /@mac/i | |
'macSoftware' | |
else | |
case @options[:platform] | |
when /iphone/i | |
'software' | |
when /mac/i | |
'macSoftware' | |
else | |
'iPadSoftware' | |
end | |
end | |
end | |
def find_itunes_icon | |
@entity = define_entity | |
search = @terms.remove_parameters | |
search = search.gsub(/\s+/, ' ').strip | |
url = "http://itunes.apple.com/search?term=#{CGI.escape(search)}&entity=#{@entity}" | |
res = Net::HTTP.get_response(URI.parse(url)).body | |
begin | |
json = JSON.parse(res) | |
selected = json['results'][0] | |
description = selected['description'].split(/\n/)[0] | |
title = selected['trackName'] | |
dev = selected['sellerName'] | |
warn "Found #{title} from #{dev}\n\n#{description}..." | |
icon_url = selected['artworkUrl512'].sub(/512x512/, "#{icon_size}x#{icon_size}") | |
retrieve_itunes_icon(icon_url, search) | |
rescue StandardError | |
raise 'Invalid JSON returned' | |
end | |
end | |
def retrieve_itunes_icon(icon_url, app) | |
url = URI.parse(icon_url) | |
filename = "#{app.gsub(/[^a-z0-9]+/i, '-')}_icon.#{icon_url.match(/\.(jpg|png)$/)[1]}" | |
outfile = File.join(File.expand_path('~/Desktop'), filename) | |
res = Net::HTTP.get_response(url) | |
File.open(outfile, 'w+') { |f| f.puts res.body } | |
warn "Wrote iTunes search result to #{outfile}." | |
process_ios_image(outfile, icon_size) unless @entity == 'macSoftware' | |
outfile | |
rescue StandardError | |
raise 'Failed to write icon from iTunes search.' | |
end | |
def round_corners(filename, size, round = 0.2) | |
round = (size * round).round.to_s | |
target = filename.sub(/\.\w+$/, '_round.png') | |
cmd = [ | |
"convert -size #{size}x#{size} xc:none -fill white -draw 'roundRectangle 0,0 #{size},#{size} #{round},#{round}'", | |
filename, | |
'-compose SrcIn -composite', | |
target | |
] | |
`#{cmd.join(' ')} &> /dev/null` | |
target | |
end | |
def process_ios_image(filename, size = nil) | |
size ||= icon_size | |
target = round_corners(filename, size, 0.205) | |
reduced = size - (size * 0.10) | |
reduced_rect = "#{reduced}x#{reduced}" | |
`convert -bordercolor 'rgba(0,0,0,0)' -border 50x50 -resize #{reduced_rect} #{target} #{target} &> /dev/null` | |
FileUtils.rm(filename) | |
filename.sub!(/\.\w+$/, '.png') | |
FileUtils.mv(target, filename) | |
filename | |
rescue StandardError | |
raise 'Error rounding corners' | |
end | |
end | |
options = { | |
preview: false, | |
size: DEFAULT_SIZE, | |
platform: 'mac', | |
outfile: nil | |
} | |
optparse = OptionParser.new do |opts| | |
opts.banner = "Usage: #{File.basename(__FILE__)} 'APP NAME/SEARCH TERM' [@PLATFORM] [%SIZE]" | |
opts.banner += "\n @ and % modifiers override options\n\nOPTIONS:" | |
opts.on('-p', '--preview', 'Show a Quick Look preview after saving') do | |
options[:preview] = true | |
end | |
opts.on('-s', '--size SIZE', 'Size to save (small, medium, large, or pixels)') do |size| | |
options[:size] = size | |
end | |
opts.on('-d', '--device PLATFORM', 'Platform to search (mac, ios, iphone, ipad)') do |platform| | |
options[:platform] = platform if platform =~ /(mac|i(os|phone|pad))/ | |
end | |
opts.on('-o', '--output PATH', 'Path to save icon to (default ~/Desktop/ICON.png)') do |path| | |
path = File.expand_path(path) | |
raise "Directory #{File.dirname(path)} does not exist" unless File.directory?(File.dirname(path)) | |
options[:outfile] = path.sub(/\.(\w+)$/, '.png') | |
end | |
opts.on('-h', '--help', 'Display this screen') do | |
puts opts | |
exit | |
end | |
end | |
optparse.parse! | |
icon = GrabIcon.new(ARGV.join(' ').sub(/\.app$/, ''), options) | |
`qlmanage >/dev/null 2> /dev/null -p #{icon.icon} &` if options[:preview] | |
puts icon.icon |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment