Files
ggbond268--mactools/scripts/plugins/generate-plugin-project-config.rb
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

414 lines
13 KiB
Ruby
Executable File

#!/usr/bin/env ruby
# frozen_string_literal: true
require "json"
require "optparse"
require "fileutils"
require "pathname"
require "shellwords"
require "yaml"
options = {
source_dir: "Plugins",
output: "Configs/GeneratedPlugins.yml"
}
OptionParser.new do |opts|
opts.banner = "Usage: generate-plugin-project-config.rb [--source-dir Plugins] [--output Configs/GeneratedPlugins.yml]"
opts.on("--source-dir PATH", "Plugin source directory") { |value| options[:source_dir] = value }
opts.on("--output PATH", "Generated XcodeGen spec output") { |value| options[:output] = value }
end.parse!
repo_root = File.expand_path("../..", __dir__)
source_dir = File.expand_path(options[:source_dir], repo_root)
output_path = File.expand_path(options[:output], repo_root)
output_dir = File.dirname(output_path)
unless Dir.exist?(source_dir)
warn "Plugin source directory not found: #{source_dir}"
exit 1
end
def read_yaml(path)
return {} unless File.file?(path)
YAML.safe_load(File.read(path), permitted_classes: [Symbol], aliases: true) || {}
rescue Psych::SyntaxError => e
warn "Invalid YAML in #{path}: #{e.message}"
exit 1
end
def camelize_id(value)
value
.split(/[^A-Za-z0-9]+/)
.reject(&:empty?)
.map { |part| part[0].upcase + part[1..] }
.join
end
def deep_merge(left, right)
left.merge(right) do |_key, old_value, new_value|
if old_value.is_a?(Hash) && new_value.is_a?(Hash)
deep_merge(old_value, new_value)
else
new_value
end
end
end
def relative_to_output_dir(path, output_dir)
Pathname(File.expand_path(path)).relative_path_from(Pathname(output_dir)).to_s
end
def normalize_fragment_path(plugin_relative_dir, item, repo_root, output_dir)
case item
when String
{ "path" => relative_to_output_dir(File.join(repo_root, plugin_relative_dir, item), output_dir) }
when Hash
normalized = item.transform_keys(&:to_s)
path = normalized["path"]
if path && !path.start_with?("/", "$(")
absolute_path = if path.start_with?("#{plugin_relative_dir}/")
File.join(repo_root, path)
else
File.join(repo_root, plugin_relative_dir, path)
end
normalized["path"] = relative_to_output_dir(absolute_path, output_dir)
end
normalized
else
item
end
end
def split_setting_words(value)
return [] if value.nil? || value.to_s.strip.empty?
Shellwords.split(value.to_s)
rescue ArgumentError
value.to_s.split
end
def collect_words(target, value)
split_setting_words(value).each do |word|
target << word unless target.include?(word)
end
end
def collect_ldflags(target, value)
words = split_setting_words(value)
index = 0
while index < words.length
word = words[index]
if ["-framework", "-weak_framework", "-F"].include?(word) && index + 1 < words.length
flag = "#{word} #{words[index + 1]}"
target << flag unless target.include?(flag)
index += 2
else
target << word unless target.include?(word)
index += 1
end
end
end
def yaml_scalar(value)
case value
when true
"true"
when false
"false"
when Numeric
value.to_s
else
string = value.to_s
if string.empty? || string.match?(/[:#\[\]\{\},&\*!\|>'"%@`]/) || string.match?(/\A(?:true|false|null|yes|no|on|off|\d)/i) || string.include?("$(")
string.dump
else
string
end
end
end
def write_yaml(io, value, indent = 0)
spaces = " " * indent
case value
when Hash
value.each do |key, child|
if child.is_a?(Hash)
io << "#{spaces}#{key}:\n"
write_yaml(io, child, indent + 2)
elsif child.is_a?(Array)
io << "#{spaces}#{key}:\n"
write_yaml(io, child, indent + 2)
else
io << "#{spaces}#{key}: #{yaml_scalar(child)}\n"
end
end
when Array
value.each do |child|
if child.is_a?(Hash)
first = true
child.each do |key, nested|
prefix = first ? "#{spaces}- " : "#{" " * (indent + 2)}"
if nested.is_a?(Hash) || nested.is_a?(Array)
io << "#{prefix}#{key}:\n"
write_yaml(io, nested, indent + 4)
else
io << "#{prefix}#{key}: #{yaml_scalar(nested)}\n"
end
first = false
end
else
io << "#{spaces}- #{yaml_scalar(child)}\n"
end
end
else
io << "#{spaces}#{yaml_scalar(value)}\n"
end
end
plugin_roots = Dir.children(source_dir)
.map { |entry| File.join(source_dir, entry) }
.select { |path| File.file?(File.join(path, "plugin.json")) }
.sort_by { |path| File.basename(path).downcase }
targets = {}
plugin_schemes = {}
plugin_bundle_targets = []
plugin_core_targets = []
test_include_paths = []
test_ldflags = []
plugin_roots.each do |plugin_root|
manifest_path = File.join(plugin_root, "plugin.json")
manifest = JSON.parse(File.read(manifest_path))
fragment = read_yaml(File.join(plugin_root, "project.yml")).transform_keys(&:to_s)
build = (manifest["build"] || {}).merge(fragment.fetch("build", {}))
plugin_id = manifest.fetch("id")
plugin_relative_dir = Pathname(File.expand_path(plugin_root)).relative_path_from(Pathname(repo_root)).to_s
scheme = build["scheme"] || "#{camelize_id(plugin_id)}Plugin"
module_name = build["moduleName"] || manifest["factoryClass"].to_s.split(".").first || scheme
bundle_relative_path = manifest.fetch("bundleRelativePath")
product_name = build["productName"] || File.basename(bundle_relative_path, ".bundle")
bundle_module_name = build["bundleModuleName"] || "#{module_name}Bundle"
core_target = build["coreTarget"] || "#{scheme}Core"
bundle_target = build["bundleTarget"] || scheme
common_settings = {
"GENERATE_INFOPLIST_FILE" => "YES",
"SWIFT_VERSION" => "6.0",
"DEVELOPMENT_LANGUAGE" => "zh-Hans",
"MACOSX_DEPLOYMENT_TARGET" => "14.0",
"CODE_SIGN_STYLE" => "Automatic",
"DEVELOPMENT_TEAM" => "$(DEVELOPMENT_TEAM)"
}
shared_settings = fragment.fetch("settings", {})
core_settings = deep_merge(
{
"base" => common_settings.merge(
"PRODUCT_MODULE_NAME" => module_name,
"PRODUCT_NAME" => core_target,
"PRODUCT_BUNDLE_IDENTIFIER" => "$(BUNDLE_IDENTIFIER_PREFIX).mactools.plugins.#{plugin_id}.core"
)
},
shared_settings
)
core_settings = deep_merge(core_settings, fragment.dig("core", "settings") || {})
bundle_settings = deep_merge(
{
"base" => common_settings.merge(
"PRODUCT_NAME" => product_name,
"PRODUCT_MODULE_NAME" => bundle_module_name,
"PRODUCT_BUNDLE_IDENTIFIER" => "$(BUNDLE_IDENTIFIER_PREFIX).mactools.plugins.#{plugin_id}"
)
},
shared_settings
)
bundle_settings = deep_merge(bundle_settings, fragment.dig("bundle", "settings") || {})
[core_settings, bundle_settings, fragment.dig("tests", "settings") || {}].each do |settings|
base = settings["base"] || {}
collect_words(test_include_paths, base["SWIFT_INCLUDE_PATHS"])
collect_ldflags(test_ldflags, base["OTHER_LDFLAGS"])
end
core_sources = [{ "path" => relative_to_output_dir(File.join(plugin_root, "Sources"), output_dir) }]
Array(fragment.dig("core", "sources")).each do |item|
core_sources << normalize_fragment_path(plugin_relative_dir, item, repo_root, output_dir)
end
bundle_sources = [{ "path" => relative_to_output_dir(File.join(plugin_root, "Bundle"), output_dir) }]
plugin_resources_dir = File.join(plugin_root, "Resources")
if Dir.exist?(plugin_resources_dir)
bundle_sources << { "path" => relative_to_output_dir(plugin_resources_dir, output_dir) }
end
Array(fragment.dig("bundle", "sources")).each do |item|
bundle_sources << normalize_fragment_path(plugin_relative_dir, item, repo_root, output_dir)
end
extra_targets = {}
extra_bundle_dependencies = []
extra_scheme_targets = []
generated_post_build_scripts = []
Array(fragment.fetch("targets", {})).each do |target_name, target_spec|
target_name = target_name.to_s
normalized_spec = target_spec.transform_keys(&:to_s)
normalized_sources = []
Array(normalized_spec["sources"]).each do |item|
normalized_sources << normalize_fragment_path(plugin_relative_dir, item, repo_root, output_dir)
end
normalized_settings = normalized_spec["settings"] || {}
base_settings = normalized_settings["base"] || {}
collect_words(test_include_paths, base_settings["SWIFT_INCLUDE_PATHS"])
collect_ldflags(test_ldflags, base_settings["OTHER_LDFLAGS"])
target_hash = normalized_spec.reject { |key, _| key == "bundleResourcePath" }
target_hash["sources"] = normalized_sources unless normalized_sources.empty?
target_dependencies = Array(normalized_spec["dependencies"])
target_hash["dependencies"] = target_dependencies unless target_dependencies.empty?
target_hash["settings"] = normalized_settings unless normalized_settings.empty?
extra_targets[target_name] = target_hash
resource_path = normalized_spec["bundleResourcePath"]
next unless resource_path
extra_bundle_dependencies << { "target" => target_name, "link" => false }
extra_scheme_targets << target_name
generated_post_build_scripts << {
"name" => "Copy #{target_name}",
"script" => [
"set -euo pipefail",
"resource_dir=\"$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/#{resource_path}\"",
"mkdir -p \"$resource_dir\"",
"ditto \"$BUILT_PRODUCTS_DIR/#{target_name}\" \"$resource_dir/#{target_name}\"",
"chmod 755 \"$resource_dir/#{target_name}\""
].join("\n"),
"inputFiles" => ["$(BUILT_PRODUCTS_DIR)/#{target_name}"],
"outputFiles" => ["$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/#{resource_path}/#{target_name}"]
}
end
targets.merge!(extra_targets)
targets[core_target] = {
"type" => "library.static",
"platform" => "macOS",
"deploymentTarget" => "14.0",
"configFiles" => {
"Debug" => "Debug.xcconfig",
"Release" => "Release.xcconfig"
},
"sources" => core_sources,
"dependencies" => [{ "target" => "MacToolsPluginKit" }] + Array(fragment.dig("core", "dependencies")),
"settings" => core_settings
}
bundle_post_build_scripts = Array(fragment.dig("bundle", "postBuildScripts")) + generated_post_build_scripts
bundle_target_hash = {
"type" => "bundle",
"platform" => "macOS",
"deploymentTarget" => "14.0",
"configFiles" => {
"Debug" => "Debug.xcconfig",
"Release" => "Release.xcconfig"
},
"sources" => bundle_sources,
"dependencies" => [
{ "target" => "MacToolsPluginKit" },
{ "target" => core_target, "link" => true }
] + extra_bundle_dependencies + Array(fragment.dig("bundle", "dependencies")),
"settings" => bundle_settings
}
bundle_target_hash["postBuildScripts"] = bundle_post_build_scripts unless bundle_post_build_scripts.empty?
targets[bundle_target] = bundle_target_hash
plugin_bundle_targets << bundle_target
plugin_core_targets << core_target
plugin_schemes[bundle_target] = {
"build" => {
"targets" => {
"MacToolsPluginKit" => "all",
core_target => "all",
bundle_target => "all"
}
.merge(extra_scheme_targets.to_h { |target| [target, "all"] })
},
"profile" => { "config" => "Release" },
"archive" => { "config" => "Release" }
}
end
test_settings = {
"base" => {
"PRODUCT_BUNDLE_IDENTIFIER" => "$(BUNDLE_IDENTIFIER_PREFIX).mactoolsTests",
"GENERATE_INFOPLIST_FILE" => "YES"
},
"configs" => {
"Debug" => {
"TEST_HOST" => "$(BUILT_PRODUCTS_DIR)/MacTools Dev.app/Contents/MacOS/MacTools Dev"
},
"Release" => {
"TEST_HOST" => "$(BUILT_PRODUCTS_DIR)/MacTools.app/Contents/MacOS/MacTools"
}
}
}
test_settings["base"]["SWIFT_INCLUDE_PATHS"] = test_include_paths.join(" ") unless test_include_paths.empty?
test_settings["base"]["OTHER_LDFLAGS"] = test_ldflags.join(" ") unless test_ldflags.empty?
targets["MacToolsTests"] = {
"type" => "bundle.unit-test",
"platform" => "macOS",
"deploymentTarget" => "14.0",
"sources" => [
{ "path" => relative_to_output_dir(File.join(repo_root, "Tests"), output_dir) },
{
"path" => relative_to_output_dir(File.join(repo_root, "Plugins"), output_dir),
"includes" => ["*/Tests/**"]
}
],
"dependencies" => [
{ "target" => "MacTools" },
{ "target" => "MacToolsPluginKit" }
] + plugin_core_targets.map { |target| { "target" => target } },
"settings" => test_settings
}
schemes = {
"MacTools" => {
"build" => {
"targets" => {
"MacToolsPluginKit" => "all"
}.merge(plugin_bundle_targets.to_h { |target| [target, "all"] })
.merge(
"MacTools" => "all",
"MacToolsTests" => ["test"]
)
},
"run" => { "config" => "Debug" },
"test" => {
"config" => "Debug",
"targets" => [
{
"name" => "MacToolsTests",
"parallelizable" => true
}
]
},
"profile" => { "config" => "Release" },
"archive" => { "config" => "Release" }
}
}.merge(plugin_schemes)
generated = +"# Generated by scripts/plugins/generate-plugin-project-config.rb. Do not edit.\n"
write_yaml(generated, "targets" => targets, "schemes" => schemes)
FileUtils.mkdir_p(File.dirname(output_path))
if !File.file?(output_path) || File.read(output_path) != generated
File.write(output_path, generated)
end