From c224df5d0637f525fe09b24cf5260d2b86ca597e Mon Sep 17 00:00:00 2001 From: Radu Ciobanu Date: Fri, 30 Jan 2026 15:47:04 +0000 Subject: [PATCH] Add support for iOS 18 layered icons and Xcode 14+ single-size format - Create IconCatalog class to parse Contents.json intelligently - Detect icon format types: legacy multi-size, single-size, or layered - For layered icons (all.png, dark.png, tint.png), badge all variants - Maintain backward compatibility with custom glob option - Bump version to 0.13.1 to satisfy fastlane-plugin-badge constraint (~> 0.13.0) Fixes issues with iOS 17/18 layered app icons that were breaking the badge overlay functionality. --- lib/badge.rb | 1 + lib/badge/base.rb | 2 +- lib/badge/icon_catalog.rb | 126 ++++++++++++++++++++++++++++++++++++++ lib/badge/runner.rb | 18 ++++-- 4 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 lib/badge/icon_catalog.rb diff --git a/lib/badge.rb b/lib/badge.rb index acbd3b8..2c21940 100644 --- a/lib/badge.rb +++ b/lib/badge.rb @@ -1,6 +1,7 @@ require 'badge/base' require 'badge/runner' require 'badge/options.rb' +require 'badge/icon_catalog.rb' require 'fastlane_core' diff --git a/lib/badge/base.rb b/lib/badge/base.rb index be05ec6..b24e021 100644 --- a/lib/badge/base.rb +++ b/lib/badge/base.rb @@ -1,6 +1,6 @@ module Badge - VERSION = "0.13.0" + VERSION = "0.13.1" DESCRIPTION = "Add a badge overlay to your app icon" def self.root diff --git a/lib/badge/icon_catalog.rb b/lib/badge/icon_catalog.rb new file mode 100644 index 0000000..8544a6c --- /dev/null +++ b/lib/badge/icon_catalog.rb @@ -0,0 +1,126 @@ +require 'json' +require 'pathname' + +module Badge + class IconCatalog + attr_reader :path, :format_type, :icon_files + + FORMAT_LEGACY = :legacy + FORMAT_SINGLE_SIZE = :single_size + FORMAT_LAYERED = :layered + + def initialize(appiconset_path) + @path = Pathname.new(appiconset_path) + @contents_json_path = @path.join('Contents.json') + @icon_files = [] + @format_type = nil + + detect_format + end + + # Returns list of icon file paths that should be badged + def badgeable_icons + case @format_type + when FORMAT_LAYERED + # For layered icons, badge all variants (all.png, dark.png, tint.png) + @icon_files + when FORMAT_SINGLE_SIZE + # Single size format - badge the single icon + @icon_files + when FORMAT_LEGACY + # Legacy format - badge all size variants + @icon_files + else + # Fallback to glob if format detection failed + glob_fallback + end + end + + def self.find_catalogs(search_path, glob_pattern = nil) + if glob_pattern + # Use custom glob if provided (backward compatibility) + UI.verbose "Using custom glob pattern: #{glob_pattern}".blue + return glob_pattern + end + + # Find all .appiconset directories + appiconset_dirs = Dir.glob("#{search_path}/**/*.appiconset") + + catalogs = appiconset_dirs.map { |dir| new(dir) } + UI.verbose "Found #{catalogs.count} app icon catalog(s)".blue + catalogs.each do |catalog| + UI.verbose " - #{catalog.path.basename} (#{catalog.format_type})".blue + end + + catalogs + end + + private + + def detect_format + unless File.exist?(@contents_json_path) + UI.verbose "No Contents.json found at #{@contents_json_path}, using fallback".yellow + @format_type = :unknown + return + end + + begin + contents = JSON.parse(File.read(@contents_json_path)) + images = contents['images'] || [] + + if images.empty? + UI.verbose "Contents.json has no images array".yellow + @format_type = :unknown + return + end + + # Check for layered format (iOS 18+) + # Layered icons have images with "appearances" array containing luminosity variants + has_appearances = images.any? { |img| img['appearances'] } + + if has_appearances + @format_type = FORMAT_LAYERED + @icon_files = extract_layered_icons(images) + UI.verbose "Detected layered icon format with #{@icon_files.count} variant(s)".blue + return + end + + # Check for single-size format (Xcode 14+) + # Single size has one image with size "1024x1024" and "idiom" = "universal" + if images.count == 1 && images[0]['size'] == '1024x1024' + @format_type = FORMAT_SINGLE_SIZE + @icon_files = extract_icon_files(images) + UI.verbose "Detected single-size icon format".blue + return + end + + # Legacy multi-size format + @format_type = FORMAT_LEGACY + @icon_files = extract_icon_files(images) + UI.verbose "Detected legacy multi-size icon format with #{@icon_files.count} size(s)".blue + + rescue JSON::ParserError => e + UI.error "Failed to parse Contents.json: #{e.message}".red + @format_type = :unknown + end + end + + def extract_layered_icons(images) + extract_icon_files(images) + end + + def extract_icon_files(images) + images.map do |image| + filename = image['filename'] + next unless filename + + icon_path = @path.join(filename) + icon_path.to_s if File.exist?(icon_path) && icon_path.extname.downcase == '.png' + end.compact + end + + def glob_fallback + Dir.glob("#{@path}/*.{png,PNG}") + end + end +end diff --git a/lib/badge/runner.rb b/lib/badge/runner.rb index 384d595..07afb4a 100644 --- a/lib/badge/runner.rb +++ b/lib/badge/runner.rb @@ -9,13 +9,23 @@ class Runner def run(path, options) check_tools! - glob = "/**/*.appiconset/*.{png,PNG}" - glob = options[:glob] if options[:glob] - - app_icons = Dir.glob("#{path}#{glob}") UI.verbose "Verbose active... VERSION: #{Badge::VERSION}".blue UI.verbose "Parameters: #{options.values.inspect}".blue + # If custom glob is provided, use legacy behavior for backward compatibility + if options[:glob] + UI.verbose "Using custom glob pattern (legacy mode)".blue + glob = options[:glob] + app_icons = Dir.glob("#{path}#{glob}") + else + # Use new IconCatalog approach to intelligently detect icon formats + UI.verbose "Using smart icon detection with Contents.json parsing".blue + catalogs = IconCatalog.find_catalogs(path, options[:glob]) + app_icons = catalogs.flat_map(&:badgeable_icons) + end + + UI.verbose "Found #{app_icons.count} icon file(s) to badge".blue + if options[:custom] && !File.exist?(options[:custom]) UI.error("Could not find custom badge image") UI.user_error!("Specify a valid custom badge image path!")