This commit is contained in:
Executable
+401
@@ -0,0 +1,401 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
build-local-plugins.sh --source-dir Plugins --output-dir build/LocalPlugins [--plugin Demo]...
|
||||
build-local-plugins.sh --source-dir Plugins --output-dir build/PluginRelease/Build --configuration Release --skip-catalog
|
||||
|
||||
Conventions:
|
||||
- A plugin can be an existing .mactoolsplugin directory.
|
||||
- Or a plugin source directory can contain plugin.json and the declared bundle.
|
||||
- If the declared bundle is missing and the directory has project.yml or *.xcodeproj,
|
||||
the script tries to build it with xcodebuild first.
|
||||
- --plugin accepts a plugin directory name or plugin ID. It can be repeated or
|
||||
passed as a comma-separated list.
|
||||
- Set --sign-identity or PLUGIN_CODE_SIGN_IDENTITY to sign packaged bundles.
|
||||
- Set --unsigned-build when a later packaging step will sign the bundle.
|
||||
- Set --xcodebuild or XCODEBUILD to override the xcodebuild executable.
|
||||
|
||||
Generated output:
|
||||
build/LocalPlugins/
|
||||
Packages/*.mactoolsplugin
|
||||
catalog.dev.json unless --skip-catalog is set
|
||||
USAGE
|
||||
}
|
||||
|
||||
SOURCE_DIR=""
|
||||
OUTPUT_DIR=""
|
||||
PLUGIN_FILTERS=()
|
||||
CONFIGURATION="${CONFIGURATION:-Debug}"
|
||||
DESTINATION="${DESTINATION:-}"
|
||||
XCODEBUILD_COMMAND="${XCODEBUILD:-}"
|
||||
SIGN_IDENTITY="${PLUGIN_CODE_SIGN_IDENTITY:-}"
|
||||
SKIP_CATALOG=0
|
||||
UNSIGNED_BUILD=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--source-dir)
|
||||
SOURCE_DIR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--output-dir)
|
||||
OUTPUT_DIR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--plugin)
|
||||
IFS=',' read -r -a raw_plugin_filters <<< "${2:-}"
|
||||
for raw_plugin_filter in "${raw_plugin_filters[@]}"; do
|
||||
raw_plugin_filter="${raw_plugin_filter#"${raw_plugin_filter%%[![:space:]]*}"}"
|
||||
raw_plugin_filter="${raw_plugin_filter%"${raw_plugin_filter##*[![:space:]]}"}"
|
||||
[[ -n "$raw_plugin_filter" ]] && PLUGIN_FILTERS+=("$raw_plugin_filter")
|
||||
done
|
||||
shift 2
|
||||
;;
|
||||
--configuration)
|
||||
CONFIGURATION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--destination)
|
||||
DESTINATION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--xcodebuild)
|
||||
XCODEBUILD_COMMAND="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--sign-identity)
|
||||
SIGN_IDENTITY="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--skip-catalog)
|
||||
SKIP_CATALOG=1
|
||||
shift
|
||||
;;
|
||||
--unsigned-build)
|
||||
UNSIGNED_BUILD=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$SOURCE_DIR" || -z "$OUTPUT_DIR" ]]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SOURCE_DIR="$(cd "$SOURCE_DIR" 2>/dev/null && pwd || true)"
|
||||
if [[ -z "$SOURCE_DIR" || ! -d "$SOURCE_DIR" ]]; then
|
||||
echo "Plugin source directory not found. Expected: $SOURCE_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
if [[ -z "$XCODEBUILD_COMMAND" ]]; then
|
||||
XCODEBUILD_COMMAND="$REPO_ROOT/scripts/xcodebuild-filtered.sh"
|
||||
fi
|
||||
OUTPUT_DIR="$(mkdir -p "$OUTPUT_DIR" && cd "$OUTPUT_DIR" && pwd)"
|
||||
PACKAGES_DIR="$OUTPUT_DIR/Packages"
|
||||
DERIVED_DATA_DIR="$OUTPUT_DIR/DerivedData"
|
||||
CATALOG_PATH="$OUTPUT_DIR/catalog.dev.json"
|
||||
|
||||
rm -rf "$PACKAGES_DIR"
|
||||
mkdir -p "$PACKAGES_DIR" "$DERIVED_DATA_DIR"
|
||||
|
||||
json_value() {
|
||||
local file="$1"
|
||||
local expression="$2"
|
||||
python3 - "$file" "$expression" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
path, expression = sys.argv[1:]
|
||||
data = json.load(open(path))
|
||||
value = data
|
||||
for part in expression.split("."):
|
||||
if not part:
|
||||
continue
|
||||
if isinstance(value, dict):
|
||||
value = value.get(part)
|
||||
else:
|
||||
value = None
|
||||
break
|
||||
if value is None:
|
||||
print("")
|
||||
else:
|
||||
print(value)
|
||||
PY
|
||||
}
|
||||
|
||||
discover_candidates() {
|
||||
if [[ -d "$SOURCE_DIR" && "$SOURCE_DIR" == *.mactoolsplugin ]]; then
|
||||
printf '%s\n' "$SOURCE_DIR"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -f "$SOURCE_DIR/plugin.json" ]]; then
|
||||
printf '%s\n' "$SOURCE_DIR"
|
||||
return
|
||||
fi
|
||||
|
||||
find "$SOURCE_DIR" -maxdepth 3 \( -name plugin.json -o -name '*.mactoolsplugin' \) -print \
|
||||
| while IFS= read -r path; do
|
||||
if [[ "$path" == *.mactoolsplugin ]]; then
|
||||
printf '%s\n' "$path"
|
||||
else
|
||||
dirname "$path"
|
||||
fi
|
||||
done \
|
||||
| sort -u
|
||||
}
|
||||
|
||||
matches_filter() {
|
||||
local candidate="$1"
|
||||
|
||||
if [[ ${#PLUGIN_FILTERS[@]} -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local basename
|
||||
basename="$(basename "$candidate")"
|
||||
basename="${basename%.mactoolsplugin}"
|
||||
|
||||
local id=""
|
||||
if [[ -f "$candidate/plugin.json" ]]; then
|
||||
id="$(json_value "$candidate/plugin.json" "id")"
|
||||
fi
|
||||
|
||||
local filter
|
||||
for filter in "${PLUGIN_FILTERS[@]}"; do
|
||||
[[ "$basename" == "$filter" ]] && return 0
|
||||
[[ -n "$id" && "$id" == "$filter" ]] && return 0
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
project_file_for() {
|
||||
local root="$1"
|
||||
local manifest="$2"
|
||||
local output_var="$3"
|
||||
|
||||
local configured_project
|
||||
configured_project="$(json_value "$manifest" "build.project")"
|
||||
[[ -z "$configured_project" ]] && configured_project="$(json_value "$manifest" "buildProject")"
|
||||
if [[ -n "$configured_project" ]]; then
|
||||
local project_path
|
||||
if [[ "$configured_project" = /* ]]; then
|
||||
project_path="$configured_project"
|
||||
else
|
||||
project_path="$(cd "$root" && cd "$(dirname "$configured_project")" && printf '%s/%s\n' "$(pwd)" "$(basename "$configured_project")")"
|
||||
fi
|
||||
printf -v "$output_var" '%s' "$project_path"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -f "$root/project.yml" ]]; then
|
||||
if ! (cd "$root" && xcodegen generate >/dev/null); then
|
||||
echo "Failed to generate Xcode project for $root." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
local project_path
|
||||
project_path="$(find "$root" -maxdepth 1 -name '*.xcodeproj' -print | sort | head -1)"
|
||||
printf -v "$output_var" '%s' "$project_path"
|
||||
}
|
||||
|
||||
scheme_for() {
|
||||
local root="$1"
|
||||
local project="$2"
|
||||
local manifest="$3"
|
||||
local output_var="$4"
|
||||
|
||||
local candidate_scheme
|
||||
candidate_scheme="$(json_value "$manifest" "build.scheme")"
|
||||
[[ -n "$candidate_scheme" ]] && { printf -v "$output_var" '%s' "$candidate_scheme"; return; }
|
||||
|
||||
candidate_scheme="$(json_value "$manifest" "buildScheme")"
|
||||
[[ -n "$candidate_scheme" ]] && { printf -v "$output_var" '%s' "$candidate_scheme"; return; }
|
||||
|
||||
local root_name
|
||||
root_name="$(basename "$root")"
|
||||
if "$XCODEBUILD_COMMAND" -list -project "$project" 2>/dev/null | grep -q "^[[:space:]]*$root_name$"; then
|
||||
printf -v "$output_var" '%s' "$root_name"
|
||||
return
|
||||
fi
|
||||
|
||||
candidate_scheme="$("$XCODEBUILD_COMMAND" -list -json -project "$project" 2>/dev/null \
|
||||
| python3 -c 'import json,sys; data=json.load(sys.stdin); print((data.get("project") or {}).get("schemes", [""])[0])')"
|
||||
printf -v "$output_var" '%s' "$candidate_scheme"
|
||||
}
|
||||
|
||||
build_bundle_if_needed() {
|
||||
local root="$1"
|
||||
local manifest="$2"
|
||||
local bundle_relative_path="$3"
|
||||
local bundle_name="$4"
|
||||
local output_var="$5"
|
||||
|
||||
if [[ -d "$root/$bundle_relative_path" ]]; then
|
||||
printf -v "$output_var" '%s' "$root/$bundle_relative_path"
|
||||
return
|
||||
fi
|
||||
|
||||
local project
|
||||
project_file_for "$root" "$manifest" project
|
||||
if [[ -z "$project" ]]; then
|
||||
echo "Bundle not found and no Xcode project exists: $root/$bundle_relative_path" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local scheme
|
||||
scheme_for "$root" "$project" "$manifest" scheme
|
||||
if [[ -z "$scheme" ]]; then
|
||||
echo "Unable to infer xcodebuild scheme for $root" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local derived_data
|
||||
derived_data="$DERIVED_DATA_DIR/$(basename "$root")"
|
||||
rm -rf "$derived_data"
|
||||
|
||||
local build_args=(
|
||||
-project "$project"
|
||||
-scheme "$scheme"
|
||||
-configuration "$CONFIGURATION"
|
||||
-derivedDataPath "$derived_data"
|
||||
)
|
||||
if [[ -n "$DESTINATION" ]]; then
|
||||
build_args+=(-destination "$DESTINATION")
|
||||
fi
|
||||
if [[ "$UNSIGNED_BUILD" == "1" ]]; then
|
||||
build_args+=(
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
CODE_SIGNING_REQUIRED=NO
|
||||
CODE_SIGN_IDENTITY=
|
||||
)
|
||||
fi
|
||||
build_args+=(build -quiet)
|
||||
|
||||
if ! "$XCODEBUILD_COMMAND" "${build_args[@]}"; then
|
||||
echo "Failed to build plugin bundle '$scheme' for $root." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local built_bundle
|
||||
built_bundle="$(find "$derived_data/Build/Products" -name "$bundle_name" -type d -print | sort | head -1)"
|
||||
if [[ -z "$built_bundle" ]]; then
|
||||
echo "Built bundle not found: $bundle_name" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf -v "$output_var" '%s' "$built_bundle"
|
||||
}
|
||||
|
||||
package_source_dir() {
|
||||
local root="$1"
|
||||
local output_var="$2"
|
||||
local manifest="$root/plugin.json"
|
||||
|
||||
if [[ ! -f "$manifest" ]]; then
|
||||
echo "Missing plugin.json: $root" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local plugin_id
|
||||
local bundle_relative_path
|
||||
local bundle_name
|
||||
plugin_id="$(json_value "$manifest" "id")"
|
||||
bundle_relative_path="$(json_value "$manifest" "bundleRelativePath")"
|
||||
bundle_name="$(basename "$bundle_relative_path")"
|
||||
|
||||
if [[ -z "$plugin_id" || -z "$bundle_relative_path" ]]; then
|
||||
echo "plugin.json must include id and bundleRelativePath: $manifest" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local bundle_path
|
||||
build_bundle_if_needed "$root" "$manifest" "$bundle_relative_path" "$bundle_name" bundle_path
|
||||
|
||||
local package_path="$PACKAGES_DIR/$plugin_id.mactoolsplugin"
|
||||
rm -rf "$package_path"
|
||||
mkdir -p "$package_path/$(dirname "$bundle_relative_path")"
|
||||
ditto "$manifest" "$package_path/plugin.json"
|
||||
ditto "$bundle_path" "$package_path/$bundle_relative_path"
|
||||
|
||||
if [[ -n "$SIGN_IDENTITY" ]]; then
|
||||
"$REPO_ROOT/scripts/plugins/sign-plugin-package.sh" \
|
||||
--package "$package_path" \
|
||||
--identity "$SIGN_IDENTITY"
|
||||
fi
|
||||
|
||||
[[ -d "$package_path" ]] || { echo "Failed to create plugin package: $package_path" >&2; return 1; }
|
||||
printf -v "$output_var" '%s' "$package_path"
|
||||
}
|
||||
|
||||
copy_package_dir() {
|
||||
local package="$1"
|
||||
local output_var="$2"
|
||||
local package_name
|
||||
package_name="$(basename "$package")"
|
||||
local destination="$PACKAGES_DIR/$package_name"
|
||||
rm -rf "$destination"
|
||||
"$REPO_ROOT/scripts/plugins/build-plugin-package.sh" \
|
||||
--source "$package" \
|
||||
--output-dir "$PACKAGES_DIR" >/dev/null
|
||||
printf -v "$output_var" '%s' "$destination"
|
||||
}
|
||||
|
||||
packages=()
|
||||
while IFS= read -r candidate; do
|
||||
[[ -n "$candidate" ]] || continue
|
||||
matches_filter "$candidate" || continue
|
||||
|
||||
if [[ "$candidate" == *.mactoolsplugin ]]; then
|
||||
built_package_path=""
|
||||
copy_package_dir "$candidate" built_package_path
|
||||
packages+=("$built_package_path")
|
||||
else
|
||||
built_package_path=""
|
||||
package_source_dir "$candidate" built_package_path
|
||||
packages+=("$built_package_path")
|
||||
fi
|
||||
done < <(discover_candidates)
|
||||
|
||||
if [[ ${#packages[@]} -eq 0 ]]; then
|
||||
if [[ ${#PLUGIN_FILTERS[@]} -gt 0 ]]; then
|
||||
echo "No plugin matched requested filters in $SOURCE_DIR: ${PLUGIN_FILTERS[*]}" >&2
|
||||
else
|
||||
echo "No plugins found in $SOURCE_DIR." >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_CATALOG" != "1" ]]; then
|
||||
catalog_args=()
|
||||
for package in "${packages[@]}"; do
|
||||
catalog_args+=(--package "$package")
|
||||
done
|
||||
|
||||
"$REPO_ROOT/scripts/plugins/generate-plugin-catalog.sh" \
|
||||
--mode debug \
|
||||
--output "$CATALOG_PATH" \
|
||||
"${catalog_args[@]}"
|
||||
fi
|
||||
|
||||
echo "Built ${#packages[@]} plugin package(s)."
|
||||
if [[ "$SKIP_CATALOG" != "1" ]]; then
|
||||
echo "Catalog: $CATALOG_PATH"
|
||||
fi
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
build-plugin-package.sh --source Plugin.mactoolsplugin --output-dir dist [--zip]
|
||||
|
||||
This helper validates the directory shape and copies the package into dist. It does
|
||||
not build an external plugin project; plugin repositories can call it after their
|
||||
own xcodebuild step.
|
||||
USAGE
|
||||
}
|
||||
|
||||
SOURCE=""
|
||||
OUTPUT_DIR=""
|
||||
ZIP=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--source)
|
||||
SOURCE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--output-dir)
|
||||
OUTPUT_DIR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--zip)
|
||||
ZIP=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$SOURCE" || -z "$OUTPUT_DIR" ]]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[[ -d "$SOURCE" ]] || { echo "Package directory not found: $SOURCE" >&2; exit 1; }
|
||||
[[ "$SOURCE" == *.mactoolsplugin ]] || { echo "Package must end with .mactoolsplugin" >&2; exit 1; }
|
||||
[[ -f "$SOURCE/plugin.json" ]] || { echo "Missing plugin.json in $SOURCE" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
DEST="$OUTPUT_DIR/$(basename "$SOURCE")"
|
||||
rm -rf "$DEST"
|
||||
ditto "$SOURCE" "$DEST"
|
||||
|
||||
if [[ "$ZIP" == "1" ]]; then
|
||||
ZIP_PATH="$DEST.zip"
|
||||
rm -f "$ZIP_PATH"
|
||||
ditto -c -k --sequesterRsrc --keepParent "$DEST" "$ZIP_PATH"
|
||||
echo "$ZIP_PATH"
|
||||
else
|
||||
echo "$DEST"
|
||||
fi
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
build-plugin-release-assets.sh --base-url URL --catalog-output dist/catalog.json --sign-identity "Developer ID Application: ..." [--plugin ID]...
|
||||
|
||||
Builds selected plugin packages, signs their bundles, zips them as release assets,
|
||||
generates a release catalog for those assets, and optionally writes a signed catalog.
|
||||
Without --plugin it builds all discovered plugins. --plugin can be repeated or
|
||||
passed as a comma-separated list.
|
||||
USAGE
|
||||
}
|
||||
|
||||
SOURCE_DIR="Plugins"
|
||||
BUILD_DIR="build/PluginRelease/Build"
|
||||
DIST_DIR="build/PluginRelease"
|
||||
ASSETS_DIR=""
|
||||
BASE_URL=""
|
||||
CATALOG_OUTPUT=""
|
||||
SIGNED_CATALOG_OUTPUT=""
|
||||
CATALOG_PRIVATE_KEY_BASE64="${PLUGIN_CATALOG_PRIVATE_KEY_BASE64:-}"
|
||||
SIGN_IDENTITY="${PLUGIN_CODE_SIGN_IDENTITY:-}"
|
||||
CONFIGURATION="Release"
|
||||
DESTINATION=""
|
||||
XCODEBUILD_COMMAND="${XCODEBUILD:-}"
|
||||
MINIMUM_HOST_VERSION=""
|
||||
RELEASE_NOTES_URL=""
|
||||
PLUGIN_FILTERS=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--source-dir)
|
||||
SOURCE_DIR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--build-dir)
|
||||
BUILD_DIR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--dist-dir)
|
||||
DIST_DIR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--assets-dir)
|
||||
ASSETS_DIR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--base-url)
|
||||
BASE_URL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--catalog-output)
|
||||
CATALOG_OUTPUT="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--signed-catalog-output)
|
||||
SIGNED_CATALOG_OUTPUT="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--catalog-private-key-base64)
|
||||
CATALOG_PRIVATE_KEY_BASE64="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--sign-identity)
|
||||
SIGN_IDENTITY="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--configuration)
|
||||
CONFIGURATION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--destination)
|
||||
DESTINATION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--xcodebuild)
|
||||
XCODEBUILD_COMMAND="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--minimum-host-version)
|
||||
MINIMUM_HOST_VERSION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--release-notes-url)
|
||||
RELEASE_NOTES_URL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--plugin)
|
||||
IFS=',' read -r -a raw_plugin_filters <<< "${2:-}"
|
||||
for raw_plugin_filter in "${raw_plugin_filters[@]}"; do
|
||||
raw_plugin_filter="${raw_plugin_filter#"${raw_plugin_filter%%[![:space:]]*}"}"
|
||||
raw_plugin_filter="${raw_plugin_filter%"${raw_plugin_filter##*[![:space:]]}"}"
|
||||
[[ -n "$raw_plugin_filter" ]] && PLUGIN_FILTERS+=("$raw_plugin_filter")
|
||||
done
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$BASE_URL" || -z "$CATALOG_OUTPUT" ]]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$SIGN_IDENTITY" ]]; then
|
||||
echo "--sign-identity or PLUGIN_CODE_SIGN_IDENTITY is required for plugin release assets." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$SIGNED_CATALOG_OUTPUT" && -z "$CATALOG_PRIVATE_KEY_BASE64" ]]; then
|
||||
echo "--catalog-private-key-base64 or PLUGIN_CATALOG_PRIVATE_KEY_BASE64 is required when signing the catalog." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
if [[ -z "$MINIMUM_HOST_VERSION" ]]; then
|
||||
MINIMUM_HOST_VERSION="$(awk '$1 == "MARKETING_VERSION" && $2 == "=" { print $3; exit }' Configs/AppVersion.xcconfig)"
|
||||
fi
|
||||
if [[ -z "$MINIMUM_HOST_VERSION" ]]; then
|
||||
echo "Unable to determine minimum host version from Configs/AppVersion.xcconfig." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ASSETS_DIR="${ASSETS_DIR:-$DIST_DIR/Assets}"
|
||||
rm -rf "$ASSETS_DIR"
|
||||
mkdir -p "$ASSETS_DIR" "$(dirname "$CATALOG_OUTPUT")"
|
||||
|
||||
build_args=(
|
||||
--source-dir "$SOURCE_DIR"
|
||||
--output-dir "$BUILD_DIR"
|
||||
--configuration "$CONFIGURATION"
|
||||
--sign-identity "$SIGN_IDENTITY"
|
||||
--unsigned-build
|
||||
--skip-catalog
|
||||
)
|
||||
if [[ -n "$DESTINATION" ]]; then
|
||||
build_args+=(--destination "$DESTINATION")
|
||||
fi
|
||||
if [[ -n "$XCODEBUILD_COMMAND" ]]; then
|
||||
build_args+=(--xcodebuild "$XCODEBUILD_COMMAND")
|
||||
fi
|
||||
for plugin_filter in "${PLUGIN_FILTERS[@]}"; do
|
||||
build_args+=(--plugin "$plugin_filter")
|
||||
done
|
||||
|
||||
"$REPO_ROOT/scripts/plugins/build-local-plugins.sh" "${build_args[@]}"
|
||||
|
||||
asset_paths=()
|
||||
while IFS= read -r package; do
|
||||
[[ -n "$package" ]] || continue
|
||||
zip_path="$("$REPO_ROOT/scripts/plugins/build-plugin-package.sh" \
|
||||
--source "$package" \
|
||||
--output-dir "$ASSETS_DIR" \
|
||||
--zip)"
|
||||
asset_paths+=("$zip_path")
|
||||
done < <(find "$BUILD_DIR/Packages" -maxdepth 1 -type d -name '*.mactoolsplugin' -print | sort)
|
||||
|
||||
if [[ ${#asset_paths[@]} -eq 0 ]]; then
|
||||
echo "No plugin packages were built under $BUILD_DIR/Packages." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
catalog_args=(
|
||||
--mode release
|
||||
--base-url "$BASE_URL"
|
||||
--output "$CATALOG_OUTPUT"
|
||||
--minimum-host-version "$MINIMUM_HOST_VERSION"
|
||||
)
|
||||
if [[ -n "$RELEASE_NOTES_URL" ]]; then
|
||||
catalog_args+=(--release-notes-url "$RELEASE_NOTES_URL")
|
||||
fi
|
||||
for asset in "${asset_paths[@]}"; do
|
||||
catalog_args+=(--package "$asset")
|
||||
done
|
||||
|
||||
"$REPO_ROOT/scripts/plugins/generate-plugin-catalog.sh" "${catalog_args[@]}"
|
||||
|
||||
if [[ -n "$SIGNED_CATALOG_OUTPUT" ]]; then
|
||||
"$REPO_ROOT/scripts/plugins/sign-plugin-catalog.sh" \
|
||||
--input "$CATALOG_OUTPUT" \
|
||||
--output "$SIGNED_CATALOG_OUTPUT" \
|
||||
--private-key-base64 "$CATALOG_PRIVATE_KEY_BASE64"
|
||||
fi
|
||||
|
||||
echo "Built ${#asset_paths[@]} plugin release asset(s)."
|
||||
echo "Assets: $ASSETS_DIR"
|
||||
echo "Catalog: $CATALOG_OUTPUT"
|
||||
if [[ -n "$SIGNED_CATALOG_OUTPUT" ]]; then
|
||||
echo "Signed catalog: $SIGNED_CATALOG_OUTPUT"
|
||||
fi
|
||||
Executable
+215
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
generate-plugin-catalog.sh --mode debug --output catalog.dev.json --package Demo.mactoolsplugin [--package More.mactoolsplugin]
|
||||
generate-plugin-catalog.sh --mode release --base-url https://github.com/owner/repo/releases/download/tag --output catalog.json --package Demo.mactoolsplugin.zip
|
||||
|
||||
Options:
|
||||
--mode debug|release Debug uses file:// package URLs; release uses --base-url.
|
||||
--output PATH Catalog JSON output path.
|
||||
--package PATH .mactoolsplugin directory or .mactoolsplugin.zip. Repeatable.
|
||||
--base-url URL Release asset base URL.
|
||||
--release-notes-url URL Optional release notes URL used when plugin.json omits one.
|
||||
--catalog-id ID Defaults to com.ggbond.mactools.plugins.
|
||||
--minimum-host-version VER Defaults to 0.15.2.
|
||||
--plugin-kit-version INT Override catalog PluginKit version; defaults to package manifests.
|
||||
|
||||
The script does not sign the catalog. Run sign-plugin-catalog.sh for release catalogs.
|
||||
USAGE
|
||||
}
|
||||
|
||||
MODE=""
|
||||
OUTPUT=""
|
||||
BASE_URL=""
|
||||
RELEASE_NOTES_URL=""
|
||||
CATALOG_ID="com.ggbond.mactools.plugins"
|
||||
MINIMUM_HOST_VERSION="0.15.2"
|
||||
PLUGIN_KIT_VERSION=""
|
||||
PACKAGES=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--mode)
|
||||
MODE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--output)
|
||||
OUTPUT="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--package)
|
||||
PACKAGES+=("${2:-}")
|
||||
shift 2
|
||||
;;
|
||||
--base-url)
|
||||
BASE_URL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--release-notes-url)
|
||||
RELEASE_NOTES_URL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--catalog-id)
|
||||
CATALOG_ID="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--minimum-host-version)
|
||||
MINIMUM_HOST_VERSION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--plugin-kit-version)
|
||||
PLUGIN_KIT_VERSION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$MODE" != "debug" && "$MODE" != "release" ]]; then
|
||||
echo "--mode must be debug or release." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$OUTPUT" || ${#PACKAGES[@]} -eq 0 ]]; then
|
||||
echo "--output and at least one --package are required." >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$MODE" == "release" && -z "$BASE_URL" ]]; then
|
||||
echo "--base-url is required in release mode." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 - "$MODE" "$OUTPUT" "$BASE_URL" "$RELEASE_NOTES_URL" "$CATALOG_ID" "$MINIMUM_HOST_VERSION" "$PLUGIN_KIT_VERSION" "${PACKAGES[@]}" <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
mode, output, base_url, release_notes_url, catalog_id, minimum_host_version, plugin_kit_version, *packages = sys.argv[1:]
|
||||
requested_plugin_kit_version = int(plugin_kit_version) if plugin_kit_version else None
|
||||
|
||||
def directory_metrics(path: pathlib.Path):
|
||||
h = hashlib.sha256()
|
||||
size = 0
|
||||
for file_path in sorted(p for p in path.rglob("*") if p.is_file() and not any(part.startswith(".") for part in p.relative_to(path).parts)):
|
||||
rel = file_path.relative_to(path).as_posix()
|
||||
data = file_path.read_bytes()
|
||||
h.update(rel.encode("utf-8"))
|
||||
h.update(b"\0")
|
||||
h.update(data)
|
||||
h.update(b"\0")
|
||||
size += len(data)
|
||||
return h.hexdigest(), size
|
||||
|
||||
def file_metrics(path: pathlib.Path):
|
||||
h = hashlib.sha256()
|
||||
size = 0
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
size += len(chunk)
|
||||
return h.hexdigest(), size
|
||||
|
||||
def package_root(path: pathlib.Path):
|
||||
if path.suffix == ".zip":
|
||||
temp = pathlib.Path(tempfile.mkdtemp(prefix="mactools-plugin-catalog-"))
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
zf.extractall(temp)
|
||||
roots = [p for p in temp.iterdir() if p.name.endswith(".mactoolsplugin")]
|
||||
if len(roots) != 1:
|
||||
raise SystemExit(f"{path} must contain exactly one .mactoolsplugin root")
|
||||
return roots[0]
|
||||
return path
|
||||
|
||||
entries = []
|
||||
plugin_kit_versions = set()
|
||||
for raw in packages:
|
||||
package_path = pathlib.Path(raw).expanduser().resolve()
|
||||
if not package_path.exists():
|
||||
raise SystemExit(f"Package not found: {package_path}")
|
||||
|
||||
root = package_root(package_path)
|
||||
manifest_path = root / "plugin.json"
|
||||
if not manifest_path.exists():
|
||||
raise SystemExit(f"Missing plugin.json: {root}")
|
||||
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
if "pluginKitVersion" not in manifest:
|
||||
raise SystemExit(f"Missing pluginKitVersion: {manifest_path}")
|
||||
manifest_plugin_kit_version = int(manifest["pluginKitVersion"])
|
||||
plugin_kit_versions.add(manifest_plugin_kit_version)
|
||||
if requested_plugin_kit_version is not None and manifest_plugin_kit_version != requested_plugin_kit_version:
|
||||
raise SystemExit(
|
||||
f"{manifest_path} uses pluginKitVersion {manifest_plugin_kit_version}, "
|
||||
f"but --plugin-kit-version is {requested_plugin_kit_version}"
|
||||
)
|
||||
digest, size = directory_metrics(package_path) if package_path.is_dir() else file_metrics(package_path)
|
||||
if mode == "debug":
|
||||
url = package_path.as_uri()
|
||||
else:
|
||||
url = base_url.rstrip("/") + "/" + package_path.name
|
||||
|
||||
entries.append({
|
||||
"id": manifest["id"],
|
||||
"displayName": manifest.get("displayName", manifest["id"]),
|
||||
"summary": manifest.get("summary", manifest.get("displayName", manifest["id"])),
|
||||
"localizedMetadata": manifest.get("localizedMetadata"),
|
||||
"version": manifest["version"],
|
||||
"minimumHostVersion": manifest.get("minHostVersion", minimum_host_version),
|
||||
"pluginKitVersion": manifest_plugin_kit_version,
|
||||
"capabilities": manifest.get("capabilities", {
|
||||
"primaryPanel": False,
|
||||
"componentPanel": False,
|
||||
"configuration": False,
|
||||
}),
|
||||
"permissions": manifest.get("permissions", []),
|
||||
"package": {
|
||||
"url": url,
|
||||
"sha256": digest,
|
||||
"size": size,
|
||||
},
|
||||
"releaseNotesURL": manifest.get("releaseNotesURL") or release_notes_url or None,
|
||||
"category": manifest.get("category"),
|
||||
"releaseChannel": manifest.get("releaseChannel"),
|
||||
})
|
||||
|
||||
if requested_plugin_kit_version is None:
|
||||
if len(plugin_kit_versions) != 1:
|
||||
raise SystemExit(
|
||||
"Packages must use one pluginKitVersion: "
|
||||
+ ", ".join(str(version) for version in sorted(plugin_kit_versions))
|
||||
)
|
||||
catalog_plugin_kit_version = next(iter(plugin_kit_versions))
|
||||
else:
|
||||
catalog_plugin_kit_version = requested_plugin_kit_version
|
||||
|
||||
catalog = {
|
||||
"schemaVersion": 1,
|
||||
"catalogID": catalog_id,
|
||||
"generatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
||||
"minimumHostVersion": minimum_host_version,
|
||||
"pluginKitVersion": catalog_plugin_kit_version,
|
||||
"plugins": sorted(entries, key=lambda entry: entry["id"]),
|
||||
"revoked": [],
|
||||
}
|
||||
|
||||
output_path = pathlib.Path(output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(json.dumps(catalog, ensure_ascii=False, indent=2, sort_keys=True) + "\n")
|
||||
PY
|
||||
+413
@@ -0,0 +1,413 @@
|
||||
#!/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
|
||||
Executable
+138
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DEFAULT_CATALOG_ID = "com.ggbond.mactools.plugins"
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Merge newly built plugin catalog entries into the current production catalog."
|
||||
)
|
||||
parser.add_argument("--previous", default="docs/plugins/catalog.json")
|
||||
parser.add_argument("--updates", required=True)
|
||||
parser.add_argument("--plan", required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--plugin-kit-version", type=int, required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_json(path, required=True):
|
||||
try:
|
||||
return json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
if required:
|
||||
raise
|
||||
return None
|
||||
|
||||
|
||||
def unsigned(catalog):
|
||||
if catalog is None:
|
||||
return None
|
||||
result = dict(catalog)
|
||||
result.pop("signature", None)
|
||||
return result
|
||||
|
||||
|
||||
def update_field(previous, updates, key, default=None):
|
||||
if updates is not None and key in updates:
|
||||
return updates[key]
|
||||
if previous is not None and key in previous:
|
||||
return previous[key]
|
||||
return default
|
||||
|
||||
|
||||
def now_iso8601():
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
previous = unsigned(load_json(args.previous, required=False))
|
||||
plan = load_json(args.plan)
|
||||
|
||||
selected_ids = plan.get("selectedPluginIDs", [])
|
||||
removed_ids = plan.get("removedPluginIDs", [])
|
||||
updates_required = bool(selected_ids)
|
||||
updates = unsigned(load_json(args.updates, required=updates_required))
|
||||
|
||||
if previous is None and updates is None:
|
||||
raise SystemExit("No previous catalog or update catalog is available.")
|
||||
|
||||
update_entries = {
|
||||
entry["id"]: entry
|
||||
for entry in (updates or {}).get("plugins", [])
|
||||
}
|
||||
missing_updates = sorted(plugin_id for plugin_id in selected_ids if plugin_id not in update_entries)
|
||||
if missing_updates:
|
||||
raise SystemExit(
|
||||
"Update catalog is missing selected plugin entries: "
|
||||
+ ", ".join(missing_updates)
|
||||
)
|
||||
|
||||
full_release = bool(plan.get("fullRelease"))
|
||||
if full_release:
|
||||
if not update_entries:
|
||||
raise SystemExit("A full catalog release requires a non-empty update catalog.")
|
||||
merged_entries = update_entries
|
||||
base_catalog = updates
|
||||
else:
|
||||
merged_entries = {
|
||||
entry["id"]: entry
|
||||
for entry in (previous or {}).get("plugins", [])
|
||||
}
|
||||
|
||||
for plugin_id in removed_ids:
|
||||
merged_entries.pop(plugin_id, None)
|
||||
|
||||
for plugin_id in selected_ids:
|
||||
merged_entries[plugin_id] = update_entries[plugin_id]
|
||||
base_catalog = previous or updates
|
||||
|
||||
schema_version = update_field(base_catalog, updates, "schemaVersion", 1)
|
||||
plugin_kit_version = update_field(base_catalog, updates, "pluginKitVersion", args.plugin_kit_version)
|
||||
if plugin_kit_version != args.plugin_kit_version:
|
||||
raise SystemExit(
|
||||
"Merged catalog pluginKitVersion does not match the expected release version "
|
||||
f"({plugin_kit_version} != {args.plugin_kit_version})."
|
||||
)
|
||||
incompatible_entries = sorted(
|
||||
entry["id"]
|
||||
for entry in merged_entries.values()
|
||||
if entry.get("pluginKitVersion", plugin_kit_version) != plugin_kit_version
|
||||
)
|
||||
if incompatible_entries:
|
||||
raise SystemExit(
|
||||
"Merged catalog would mix pluginKitVersion values. "
|
||||
"Rebuild these plugins for the current PluginKit: "
|
||||
+ ", ".join(incompatible_entries)
|
||||
)
|
||||
|
||||
catalog = {
|
||||
"schemaVersion": schema_version,
|
||||
"catalogID": update_field(base_catalog, updates, "catalogID", DEFAULT_CATALOG_ID),
|
||||
"generatedAt": now_iso8601(),
|
||||
"minimumHostVersion": update_field(base_catalog, updates, "minimumHostVersion", "0.15.2"),
|
||||
"pluginKitVersion": plugin_kit_version,
|
||||
"plugins": sorted(merged_entries.values(), key=lambda entry: entry["id"]),
|
||||
"revoked": update_field(base_catalog, updates, "revoked", []),
|
||||
}
|
||||
|
||||
output = Path(args.output)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(
|
||||
json.dumps(catalog, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
print(
|
||||
f"Merged catalog: {len(selected_ids)} updated, "
|
||||
f"{len(removed_ids)} removed, {len(catalog['plugins'])} total plugin(s)."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+357
@@ -0,0 +1,357 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DEFAULT_SHARED_PATHS = []
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Plan an incremental MacTools plugin release."
|
||||
)
|
||||
parser.add_argument("--mode", choices=["auto", "all", "selected"], default="auto")
|
||||
parser.add_argument("--plugins", default="", help="Comma-separated plugin IDs for selected mode.")
|
||||
parser.add_argument("--plugin", action="append", default=[], help="Plugin ID or directory name. Repeatable.")
|
||||
parser.add_argument("--source-dir", default="Plugins")
|
||||
parser.add_argument("--previous-catalog", default="docs/plugins/catalog.json")
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument(
|
||||
"--shared-path",
|
||||
action="append",
|
||||
default=[],
|
||||
help=(
|
||||
"Repository path that forces existing plugins to rebuild when changed. "
|
||||
"Repeatable; defaults to none."
|
||||
),
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_json(path):
|
||||
try:
|
||||
return json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def git(args, check=True, capture=True):
|
||||
kwargs = {
|
||||
"check": check,
|
||||
"text": True,
|
||||
}
|
||||
if capture:
|
||||
kwargs["stdout"] = subprocess.PIPE
|
||||
kwargs["stderr"] = subprocess.PIPE
|
||||
return subprocess.run(["git", *args], **kwargs)
|
||||
|
||||
|
||||
def git_ref_exists(ref):
|
||||
return git(["rev-parse", "--verify", "--quiet", ref], check=False).returncode == 0
|
||||
|
||||
|
||||
def changed_paths(ref, paths):
|
||||
existing_paths = [path for path in paths if path]
|
||||
if not existing_paths:
|
||||
return []
|
||||
result = git(["diff", "--name-only", f"{ref}..HEAD", "--", *existing_paths])
|
||||
return [line for line in result.stdout.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def package_relevant_plugin_paths(paths):
|
||||
result = []
|
||||
for path in paths:
|
||||
parts = Path(path).parts
|
||||
if "Tests" in parts:
|
||||
continue
|
||||
result.append(path)
|
||||
return result
|
||||
|
||||
|
||||
def version_parts(version):
|
||||
values = []
|
||||
for component in version.split("."):
|
||||
match = re.match(r"(\d+)", component)
|
||||
values.append(int(match.group(1)) if match else 0)
|
||||
return values
|
||||
|
||||
|
||||
def compare_versions(lhs, rhs):
|
||||
left = version_parts(lhs)
|
||||
right = version_parts(rhs)
|
||||
count = max(len(left), len(right))
|
||||
left.extend([0] * (count - len(left)))
|
||||
right.extend([0] * (count - len(right)))
|
||||
if left < right:
|
||||
return -1
|
||||
if left > right:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def plugin_release_tag(entry):
|
||||
candidates = [
|
||||
((entry.get("package") or {}).get("url") or ""),
|
||||
entry.get("releaseNotesURL") or "",
|
||||
]
|
||||
for value in candidates:
|
||||
match = re.search(r"/releases/(?:download|tag)/([^/]+)", value)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def read_plugins(source_dir):
|
||||
root = Path(source_dir)
|
||||
plugins = {}
|
||||
for manifest_path in sorted(root.glob("*/plugin.json")):
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
plugin_id = manifest["id"]
|
||||
plugins[plugin_id] = {
|
||||
"id": plugin_id,
|
||||
"directoryName": manifest_path.parent.name,
|
||||
"path": manifest_path.parent.as_posix(),
|
||||
"manifestPath": manifest_path.as_posix(),
|
||||
"version": manifest["version"],
|
||||
"pluginKitVersion": manifest["pluginKitVersion"],
|
||||
"displayName": manifest.get("displayName", plugin_id),
|
||||
}
|
||||
return plugins
|
||||
|
||||
|
||||
def current_plugin_kit_version(plugins):
|
||||
versions = sorted({plugin["pluginKitVersion"] for plugin in plugins.values()})
|
||||
if len(versions) != 1:
|
||||
raise SystemExit(
|
||||
"Plugin manifests must use one pluginKitVersion for a release: "
|
||||
+ ", ".join(str(version) for version in versions)
|
||||
)
|
||||
return versions[0]
|
||||
|
||||
|
||||
def normalize_selected(raw_values, plugins):
|
||||
values = []
|
||||
for raw in raw_values:
|
||||
values.extend(part.strip() for part in raw.split(",") if part.strip())
|
||||
|
||||
by_directory = {plugin["directoryName"]: plugin_id for plugin_id, plugin in plugins.items()}
|
||||
selected = []
|
||||
unknown = []
|
||||
for value in values:
|
||||
plugin_id = value if value in plugins else by_directory.get(value)
|
||||
if plugin_id is None:
|
||||
unknown.append(value)
|
||||
elif plugin_id not in selected:
|
||||
selected.append(plugin_id)
|
||||
return selected, unknown
|
||||
|
||||
|
||||
def plan_release(args):
|
||||
plugins = read_plugins(args.source_dir)
|
||||
plugin_kit_version = current_plugin_kit_version(plugins)
|
||||
previous_catalog = load_json(args.previous_catalog)
|
||||
previous_catalog_plugin_kit_version = (previous_catalog or {}).get("pluginKitVersion")
|
||||
previous_entries = {
|
||||
entry["id"]: entry
|
||||
for entry in (previous_catalog or {}).get("plugins", [])
|
||||
}
|
||||
shared_paths = args.shared_path or DEFAULT_SHARED_PATHS
|
||||
selected_inputs, unknown_inputs = normalize_selected(
|
||||
[args.plugins, *args.plugin],
|
||||
plugins,
|
||||
)
|
||||
|
||||
if unknown_inputs:
|
||||
raise SystemExit("Unknown plugin selection: " + ", ".join(unknown_inputs))
|
||||
|
||||
removed_plugin_ids = []
|
||||
if args.mode != "selected":
|
||||
removed_plugin_ids = sorted(set(previous_entries) - set(plugins))
|
||||
selected = []
|
||||
reasons = {}
|
||||
errors = []
|
||||
full_release = False
|
||||
|
||||
if (
|
||||
previous_catalog_plugin_kit_version is not None
|
||||
and previous_catalog_plugin_kit_version != plugin_kit_version
|
||||
):
|
||||
# A catalog must never mix packages built against different PluginKit
|
||||
# ABIs. The first release of a new ABI therefore replaces the entire
|
||||
# catalog, even when the caller selected the default auto mode.
|
||||
full_release = True
|
||||
|
||||
def select(plugin_id, reason):
|
||||
if plugin_id not in selected:
|
||||
selected.append(plugin_id)
|
||||
reasons.setdefault(plugin_id, []).append(reason)
|
||||
|
||||
def previous_entry_plugin_kit_version(entry):
|
||||
if "pluginKitVersion" in entry:
|
||||
return entry["pluginKitVersion"]
|
||||
if previous_catalog_plugin_kit_version is not None:
|
||||
return previous_catalog_plugin_kit_version
|
||||
raise SystemExit(
|
||||
f"Previous catalog entry is missing pluginKitVersion: {entry.get('id', '(unknown)')}"
|
||||
)
|
||||
|
||||
def handle_plugin_kit_change(plugin_id, plugin, previous_entry, version_cmp):
|
||||
previous_version = previous_entry_plugin_kit_version(previous_entry)
|
||||
if previous_version == plugin["pluginKitVersion"]:
|
||||
return False
|
||||
|
||||
reason = f"pluginKitVersion {previous_version} -> {plugin['pluginKitVersion']}"
|
||||
if version_cmp <= 0:
|
||||
errors.append(
|
||||
f"{plugin_id}: {reason}, but version is still {plugin['version']}. "
|
||||
f"Bump {plugin['manifestPath']} version before publishing a PluginKit update."
|
||||
)
|
||||
else:
|
||||
select(plugin_id, reason)
|
||||
return True
|
||||
|
||||
if (
|
||||
args.mode == "selected"
|
||||
and previous_catalog_plugin_kit_version is not None
|
||||
and previous_catalog_plugin_kit_version != plugin_kit_version
|
||||
and set(selected_inputs) != set(plugins)
|
||||
):
|
||||
errors.append(
|
||||
"Current pluginKitVersion differs from the previous catalog "
|
||||
f"({previous_catalog_plugin_kit_version} -> {plugin_kit_version}). "
|
||||
"Use --mode all so the catalog cannot mix incompatible plugin packages."
|
||||
)
|
||||
|
||||
if args.mode == "all":
|
||||
full_release = True
|
||||
for plugin_id, plugin in sorted(plugins.items()):
|
||||
previous_entry = previous_entries.get(plugin_id)
|
||||
if previous_entry is not None:
|
||||
previous_version = previous_entry["version"]
|
||||
current_version = plugin["version"]
|
||||
version_cmp = compare_versions(current_version, previous_version)
|
||||
if version_cmp < 0:
|
||||
errors.append(
|
||||
f"{plugin_id}: version cannot go backwards "
|
||||
f"({previous_version} -> {current_version})"
|
||||
)
|
||||
continue
|
||||
handle_plugin_kit_change(plugin_id, plugin, previous_entry, version_cmp)
|
||||
select(plugin_id, "all mode")
|
||||
elif args.mode == "selected":
|
||||
if not selected_inputs:
|
||||
raise SystemExit("--mode selected requires --plugins or --plugin")
|
||||
for plugin_id in selected_inputs:
|
||||
select(plugin_id, "selected mode")
|
||||
for plugin_id in selected_inputs:
|
||||
plugin = plugins[plugin_id]
|
||||
previous_entry = previous_entries.get(plugin_id)
|
||||
if previous_entry is None:
|
||||
continue
|
||||
|
||||
previous_version = previous_entry["version"]
|
||||
current_version = plugin["version"]
|
||||
version_cmp = compare_versions(current_version, previous_version)
|
||||
if version_cmp < 0:
|
||||
errors.append(
|
||||
f"{plugin_id}: version cannot go backwards "
|
||||
f"({previous_version} -> {current_version})"
|
||||
)
|
||||
continue
|
||||
|
||||
handle_plugin_kit_change(plugin_id, plugin, previous_entry, version_cmp)
|
||||
elif not previous_catalog:
|
||||
full_release = True
|
||||
for plugin_id in sorted(plugins):
|
||||
select(plugin_id, "no previous catalog")
|
||||
else:
|
||||
for plugin_id, plugin in sorted(plugins.items()):
|
||||
previous_entry = previous_entries.get(plugin_id)
|
||||
if previous_entry is None:
|
||||
select(plugin_id, "new plugin")
|
||||
continue
|
||||
|
||||
previous_version = previous_entry["version"]
|
||||
current_version = plugin["version"]
|
||||
version_cmp = compare_versions(current_version, previous_version)
|
||||
if version_cmp < 0:
|
||||
errors.append(
|
||||
f"{plugin_id}: version cannot go backwards "
|
||||
f"({previous_version} -> {current_version})"
|
||||
)
|
||||
continue
|
||||
if handle_plugin_kit_change(plugin_id, plugin, previous_entry, version_cmp):
|
||||
continue
|
||||
if version_cmp > 0:
|
||||
select(plugin_id, f"version {previous_version} -> {current_version}")
|
||||
continue
|
||||
|
||||
tag = plugin_release_tag(previous_entry)
|
||||
if not tag:
|
||||
errors.append(f"{plugin_id}: previous release tag could not be inferred from catalog")
|
||||
continue
|
||||
if not git_ref_exists(tag):
|
||||
errors.append(f"{plugin_id}: previous release tag is not available locally: {tag}")
|
||||
continue
|
||||
|
||||
plugin_changes = package_relevant_plugin_paths(changed_paths(tag, [plugin["path"]]))
|
||||
shared_changes = changed_paths(tag, shared_paths)
|
||||
if plugin_changes or shared_changes:
|
||||
if version_cmp <= 0:
|
||||
change_samples = (plugin_changes + shared_changes)[:5]
|
||||
errors.append(
|
||||
f"{plugin_id}: package-relevant files changed since {tag}, "
|
||||
f"but version is still {current_version}. "
|
||||
f"Bump {plugin['manifestPath']} version. Changed paths: "
|
||||
+ ", ".join(change_samples)
|
||||
)
|
||||
else:
|
||||
select(plugin_id, f"package-relevant changes since {tag}")
|
||||
|
||||
if errors:
|
||||
print("\n".join(errors), file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
selected = sorted(selected)
|
||||
plan = {
|
||||
"mode": args.mode,
|
||||
"releaseRequired": bool(selected or removed_plugin_ids),
|
||||
"fullRelease": full_release,
|
||||
"pluginKitVersion": plugin_kit_version,
|
||||
"selectedPluginIDs": selected,
|
||||
"removedPluginIDs": removed_plugin_ids,
|
||||
"reasons": {plugin_id: reasons.get(plugin_id, []) for plugin_id in selected},
|
||||
"plugins": [
|
||||
{
|
||||
"id": plugin_id,
|
||||
"displayName": plugins[plugin_id]["displayName"],
|
||||
"version": plugins[plugin_id]["version"],
|
||||
"path": plugins[plugin_id]["path"],
|
||||
}
|
||||
for plugin_id in selected
|
||||
],
|
||||
}
|
||||
|
||||
output = Path(args.output)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(plan, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
return plan
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
plan = plan_release(args)
|
||||
if plan["selectedPluginIDs"]:
|
||||
print("Selected plugins: " + ", ".join(plan["selectedPluginIDs"]))
|
||||
elif plan["removedPluginIDs"]:
|
||||
print("No plugin packages selected; removed plugins: " + ", ".join(plan["removedPluginIDs"]))
|
||||
else:
|
||||
print("No plugin release changes detected.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
publish-plugin-release.sh --repo owner/repo --tag plugins-2026.05.17 --asset Demo.mactoolsplugin.zip [--asset More.mactoolsplugin.zip]
|
||||
|
||||
Requires GitHub CLI authenticated with release upload permissions.
|
||||
Use --allow-empty for catalog-only plugin releases with no package assets.
|
||||
USAGE
|
||||
}
|
||||
|
||||
REPO=""
|
||||
TAG=""
|
||||
TITLE=""
|
||||
NOTES_FILE=""
|
||||
PRERELEASE=0
|
||||
ALLOW_EMPTY=0
|
||||
ASSETS=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--repo)
|
||||
REPO="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--tag)
|
||||
TAG="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--title)
|
||||
TITLE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--notes-file)
|
||||
NOTES_FILE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--prerelease)
|
||||
PRERELEASE=1
|
||||
shift
|
||||
;;
|
||||
--allow-empty)
|
||||
ALLOW_EMPTY=1
|
||||
shift
|
||||
;;
|
||||
--asset)
|
||||
ASSETS+=("${2:-}")
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$REPO" || -z "$TAG" ]]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ${#ASSETS[@]} -eq 0 && "$ALLOW_EMPTY" != "1" ]]; then
|
||||
echo "At least one --asset is required unless --allow-empty is set." >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
command -v gh >/dev/null || { echo "GitHub CLI 'gh' is required." >&2; exit 1; }
|
||||
|
||||
for asset in "${ASSETS[@]}"; do
|
||||
[[ -f "$asset" ]] || { echo "Release asset not found: $asset" >&2; exit 1; }
|
||||
done
|
||||
|
||||
TITLE="${TITLE:-$TAG}"
|
||||
|
||||
release_args=(--repo "$REPO" --title "$TITLE")
|
||||
if [[ -n "$NOTES_FILE" ]]; then
|
||||
[[ -f "$NOTES_FILE" ]] || { echo "Release notes file not found: $NOTES_FILE" >&2; exit 1; }
|
||||
release_args+=(--notes-file "$NOTES_FILE")
|
||||
else
|
||||
release_args+=(--notes "")
|
||||
fi
|
||||
if [[ "$PRERELEASE" == "1" ]]; then
|
||||
release_args+=(--prerelease)
|
||||
fi
|
||||
|
||||
if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
|
||||
if [[ ${#ASSETS[@]} -gt 0 ]]; then
|
||||
gh release upload "$TAG" "${ASSETS[@]}" --repo "$REPO" --clobber
|
||||
fi
|
||||
gh release edit "$TAG" "${release_args[@]}"
|
||||
else
|
||||
if [[ ${#ASSETS[@]} -gt 0 ]]; then
|
||||
gh release create "$TAG" "${ASSETS[@]}" "${release_args[@]}"
|
||||
else
|
||||
gh release create "$TAG" "${release_args[@]}"
|
||||
fi
|
||||
fi
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
sign-plugin-catalog.sh --input catalog.json --output catalog.signed.json --private-key-base64 "$KEY"
|
||||
|
||||
The private key must be an Ed25519 raw private key encoded as base64. Keep it in CI
|
||||
secrets or a local env file; do not commit it.
|
||||
USAGE
|
||||
}
|
||||
|
||||
INPUT=""
|
||||
OUTPUT=""
|
||||
PRIVATE_KEY_BASE64="${PLUGIN_CATALOG_PRIVATE_KEY_BASE64:-}"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--input)
|
||||
INPUT="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--output)
|
||||
OUTPUT="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--private-key-base64)
|
||||
PRIVATE_KEY_BASE64="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$INPUT" || -z "$OUTPUT" || -z "$PRIVATE_KEY_BASE64" ]]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 - "$INPUT" "$OUTPUT" "$PRIVATE_KEY_BASE64" <<'PY'
|
||||
import base64
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
try:
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
except Exception as exc:
|
||||
raise SystemExit("Python package 'cryptography' is required to sign catalogs.") from exc
|
||||
|
||||
input_path, output_path, private_key_b64 = sys.argv[1:]
|
||||
catalog = json.loads(pathlib.Path(input_path).read_text())
|
||||
catalog.pop("signature", None)
|
||||
payload = json.dumps(catalog, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
private_key = Ed25519PrivateKey.from_private_bytes(base64.b64decode(private_key_b64))
|
||||
signature = private_key.sign(payload)
|
||||
catalog["signature"] = {
|
||||
"algorithm": "ed25519",
|
||||
"value": base64.b64encode(signature).decode("ascii"),
|
||||
}
|
||||
output = pathlib.Path(output_path)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(catalog, ensure_ascii=False, indent=2, sort_keys=True) + "\n")
|
||||
PY
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
sign-plugin-package.sh --package Demo.mactoolsplugin --identity "Developer ID Application: ..." [--keychain build.keychain-db]
|
||||
|
||||
Signs the bundle declared by plugin.json.bundleRelativePath. Debug builds may use
|
||||
an Apple Development identity; release packages should use Developer ID.
|
||||
USAGE
|
||||
}
|
||||
|
||||
PACKAGE=""
|
||||
IDENTITY="${CODE_SIGN_IDENTITY:-}"
|
||||
KEYCHAIN="${CODE_SIGN_KEYCHAIN:-}"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--package)
|
||||
PACKAGE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--identity)
|
||||
IDENTITY="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--keychain)
|
||||
KEYCHAIN="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$PACKAGE" || -z "$IDENTITY" ]]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BUNDLE_RELATIVE_PATH="$(python3 - "$PACKAGE/plugin.json" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
print(json.load(open(sys.argv[1]))["bundleRelativePath"])
|
||||
PY
|
||||
)"
|
||||
BUNDLE_PATH="$PACKAGE/$BUNDLE_RELATIVE_PATH"
|
||||
[[ -d "$BUNDLE_PATH" ]] || { echo "Bundle not found: $BUNDLE_PATH" >&2; exit 1; }
|
||||
|
||||
SIGN_PATHS=()
|
||||
while IFS= read -r relative_path; do
|
||||
[[ -n "$relative_path" ]] || continue
|
||||
[[ "$relative_path" != /* ]] || { echo "package.signPaths entries must be relative: $relative_path" >&2; exit 1; }
|
||||
[[ "$relative_path" != *".."* ]] || { echo "package.signPaths entries must not contain '..': $relative_path" >&2; exit 1; }
|
||||
SIGN_PATHS+=("$PACKAGE/$relative_path")
|
||||
done < <(python3 - "$PACKAGE/plugin.json" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
data = json.load(open(sys.argv[1]))
|
||||
for path in ((data.get("package") or {}).get("signPaths") or []):
|
||||
print(path)
|
||||
PY
|
||||
)
|
||||
|
||||
sign_args=(--force --options runtime --timestamp --sign "$IDENTITY")
|
||||
if [[ -n "$KEYCHAIN" ]]; then
|
||||
sign_args+=(--keychain "$KEYCHAIN")
|
||||
fi
|
||||
|
||||
if ((${#SIGN_PATHS[@]} > 0)); then
|
||||
for path in "${SIGN_PATHS[@]}"; do
|
||||
[[ -e "$path" ]] || { echo "Sign path not found: $path" >&2; exit 1; }
|
||||
codesign "${sign_args[@]}" "$path"
|
||||
codesign --verify --strict "$path"
|
||||
done
|
||||
fi
|
||||
|
||||
codesign "${sign_args[@]}" "$BUNDLE_PATH"
|
||||
codesign --verify --strict --deep "$BUNDLE_PATH"
|
||||
Executable
+316
@@ -0,0 +1,316 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
sync-debug-plugins.sh --source-dir Plugins --products-dir build/DerivedData/Build/Products/Debug --output-dir build/LocalPlugins
|
||||
|
||||
Synchronizes Debug plugin bundles already built by the main MacTools scheme into
|
||||
development .mactoolsplugin packages, generates a local debug catalog, and copies
|
||||
the packages into the MacTools Dev installed plugin store.
|
||||
|
||||
This script does not run xcodebuild. Run it after the Debug app build.
|
||||
USAGE
|
||||
}
|
||||
|
||||
SOURCE_DIR=""
|
||||
PRODUCTS_DIR=""
|
||||
OUTPUT_DIR=""
|
||||
INSTALL_DIR="$HOME/Library/Application Support/MacTools Dev/Plugins/Installed"
|
||||
SKIP_INSTALL=0
|
||||
PLUGIN_FILTERS=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--source-dir)
|
||||
SOURCE_DIR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--products-dir)
|
||||
PRODUCTS_DIR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--output-dir)
|
||||
OUTPUT_DIR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--install-dir)
|
||||
INSTALL_DIR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--plugin)
|
||||
IFS=',' read -r -a raw_plugin_filters <<< "${2:-}"
|
||||
for raw_plugin_filter in "${raw_plugin_filters[@]}"; do
|
||||
raw_plugin_filter="${raw_plugin_filter#"${raw_plugin_filter%%[![:space:]]*}"}"
|
||||
raw_plugin_filter="${raw_plugin_filter%"${raw_plugin_filter##*[![:space:]]}"}"
|
||||
[[ -n "$raw_plugin_filter" ]] && PLUGIN_FILTERS+=("$raw_plugin_filter")
|
||||
done
|
||||
shift 2
|
||||
;;
|
||||
--skip-install)
|
||||
SKIP_INSTALL=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$SOURCE_DIR" || -z "$PRODUCTS_DIR" || -z "$OUTPUT_DIR" ]]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SOURCE_DIR="$(cd "$SOURCE_DIR" 2>/dev/null && pwd || true)"
|
||||
if [[ -z "$SOURCE_DIR" || ! -d "$SOURCE_DIR" ]]; then
|
||||
echo "Plugin source directory not found: $SOURCE_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PRODUCTS_DIR="$(cd "$PRODUCTS_DIR" 2>/dev/null && pwd || true)"
|
||||
if [[ -z "$PRODUCTS_DIR" || ! -d "$PRODUCTS_DIR" ]]; then
|
||||
echo "Debug build products directory not found. Run 'make build' first: $PRODUCTS_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
OUTPUT_DIR="$(mkdir -p "$OUTPUT_DIR" && cd "$OUTPUT_DIR" && pwd)"
|
||||
PACKAGES_DIR="$OUTPUT_DIR/Packages"
|
||||
CATALOG_PATH="$OUTPUT_DIR/catalog.dev.json"
|
||||
STATE_DIR="$OUTPUT_DIR/.sync-state"
|
||||
|
||||
mkdir -p "$PACKAGES_DIR"
|
||||
mkdir -p "$STATE_DIR"
|
||||
if [[ "$SKIP_INSTALL" != "1" ]]; then
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
copy_package_to_installed_store() {
|
||||
local package_path="$1"
|
||||
local plugin_id="$2"
|
||||
local destination="$INSTALL_DIR/$plugin_id.mactoolsplugin"
|
||||
local staging="$INSTALL_DIR/.$plugin_id.syncing.$$.mactoolsplugin"
|
||||
|
||||
rm -rf "$staging"
|
||||
ditto "$package_path" "$staging"
|
||||
rm -rf "$destination"
|
||||
mv "$staging" "$destination"
|
||||
}
|
||||
|
||||
discover_plugin_records() {
|
||||
local plugin_filters_serialized=""
|
||||
for plugin_filter in "${PLUGIN_FILTERS[@]-}"; do
|
||||
plugin_filters_serialized+=$'\n'"$plugin_filter"
|
||||
done
|
||||
|
||||
python3 - "$SOURCE_DIR" "$PRODUCTS_DIR" "$plugin_filters_serialized" <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
source_dir = pathlib.Path(sys.argv[1])
|
||||
products_dir = pathlib.Path(sys.argv[2])
|
||||
serialized_filters = sys.argv[3] if len(sys.argv) > 3 else ""
|
||||
filters = {item for item in serialized_filters.splitlines() if item}
|
||||
|
||||
def emit_error(message: str) -> None:
|
||||
print(message, file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
def validate_field(value: str) -> str:
|
||||
if "\t" in value or "\n" in value:
|
||||
emit_error(f"Unsupported tab or newline in plugin sync field: {value!r}")
|
||||
return value
|
||||
|
||||
def discover_candidates() -> list[pathlib.Path]:
|
||||
if (source_dir / "plugin.json").is_file():
|
||||
return [source_dir]
|
||||
|
||||
candidates = []
|
||||
for root, dirs, files in os.walk(source_dir):
|
||||
root_path = pathlib.Path(root)
|
||||
depth = len(root_path.relative_to(source_dir).parts)
|
||||
if depth >= 3:
|
||||
dirs[:] = []
|
||||
if "plugin.json" in files:
|
||||
candidates.append(root_path)
|
||||
|
||||
return sorted(set(candidates), key=lambda path: path.name.lower())
|
||||
|
||||
def input_fingerprint(manifest: pathlib.Path, bundle: pathlib.Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
|
||||
def is_ignored(path: pathlib.Path) -> bool:
|
||||
relative_parts = path.relative_to(bundle).parts
|
||||
return "_CodeSignature" in relative_parts
|
||||
|
||||
def update_file(label: str, path: pathlib.Path) -> None:
|
||||
stat = path.lstat()
|
||||
digest.update(label.encode("utf-8"))
|
||||
digest.update(b"\0file\0")
|
||||
digest.update(oct(stat.st_mode & 0o7777).encode("utf-8"))
|
||||
digest.update(b"\0")
|
||||
with path.open("rb") as file:
|
||||
for chunk in iter(lambda: file.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
digest.update(b"\0")
|
||||
|
||||
def update_symlink(label: str, path: pathlib.Path) -> None:
|
||||
stat = path.lstat()
|
||||
digest.update(label.encode("utf-8"))
|
||||
digest.update(b"\0symlink\0")
|
||||
digest.update(oct(stat.st_mode & 0o7777).encode("utf-8"))
|
||||
digest.update(b"\0")
|
||||
digest.update(os.readlink(path).encode("utf-8"))
|
||||
digest.update(b"\0")
|
||||
|
||||
update_file("plugin.json", manifest)
|
||||
|
||||
for path in sorted(bundle.rglob("*"), key=lambda item: item.relative_to(bundle).as_posix()):
|
||||
if is_ignored(path):
|
||||
continue
|
||||
|
||||
relative = path.relative_to(bundle).as_posix()
|
||||
if path.is_symlink():
|
||||
update_symlink(relative, path)
|
||||
elif path.is_file():
|
||||
update_file(relative, path)
|
||||
|
||||
return digest.hexdigest()
|
||||
|
||||
records = []
|
||||
for plugin_root in discover_candidates():
|
||||
manifest = plugin_root / "plugin.json"
|
||||
with manifest.open("r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
|
||||
plugin_id = data.get("id") or ""
|
||||
bundle_relative_path = data.get("bundleRelativePath") or ""
|
||||
if not plugin_id or not bundle_relative_path:
|
||||
emit_error(f"plugin.json must include id and bundleRelativePath: {manifest}")
|
||||
|
||||
if filters and plugin_root.name not in filters and plugin_id not in filters:
|
||||
continue
|
||||
|
||||
bundle_name = pathlib.Path(bundle_relative_path).name
|
||||
bundle_path = products_dir / bundle_name
|
||||
if not bundle_path.is_dir():
|
||||
emit_error(
|
||||
f"Built plugin bundle not found for {plugin_id}: {bundle_path}\n"
|
||||
"Run 'make build' and ensure the plugin target is included in the MacTools scheme."
|
||||
)
|
||||
|
||||
records.append((
|
||||
str(plugin_root),
|
||||
str(manifest),
|
||||
plugin_id,
|
||||
bundle_relative_path,
|
||||
bundle_name,
|
||||
str(bundle_path),
|
||||
input_fingerprint(manifest, bundle_path),
|
||||
))
|
||||
|
||||
if not records:
|
||||
if filters:
|
||||
emit_error(f"No plugin matched requested filters in {source_dir}: {' '.join(sorted(filters))}")
|
||||
emit_error(f"No plugins found in {source_dir}.")
|
||||
|
||||
for record in records:
|
||||
print("\t".join(validate_field(field) for field in record))
|
||||
PY
|
||||
}
|
||||
|
||||
state_file_for_plugin() {
|
||||
local plugin_id="$1"
|
||||
local safe_name
|
||||
safe_name="$(printf '%s' "$plugin_id" | tr -c 'A-Za-z0-9._-' '_')"
|
||||
printf '%s/%s.sha256\n' "$STATE_DIR" "$safe_name"
|
||||
}
|
||||
|
||||
package_is_complete() {
|
||||
local package_path="$1"
|
||||
local bundle_relative_path="$2"
|
||||
|
||||
[[ -f "$package_path/plugin.json" && -d "$package_path/$bundle_relative_path" ]]
|
||||
}
|
||||
|
||||
packages=()
|
||||
synced_count=0
|
||||
installed_count=0
|
||||
skipped_count=0
|
||||
while IFS=$'\t' read -r plugin_root manifest plugin_id bundle_relative_path bundle_name bundle_path fingerprint; do
|
||||
[[ -n "$plugin_root" ]] || continue
|
||||
|
||||
package_path="$PACKAGES_DIR/$plugin_id.mactoolsplugin"
|
||||
state_path="$(state_file_for_plugin "$plugin_id")"
|
||||
previous_fingerprint=""
|
||||
package_synced=0
|
||||
if [[ -f "$state_path" ]]; then
|
||||
previous_fingerprint="$(<"$state_path")"
|
||||
fi
|
||||
|
||||
if [[ "$fingerprint" != "$previous_fingerprint" ]] || ! package_is_complete "$package_path" "$bundle_relative_path"; then
|
||||
rm -rf "$package_path"
|
||||
mkdir -p "$package_path/$(dirname "$bundle_relative_path")"
|
||||
ditto "$manifest" "$package_path/plugin.json"
|
||||
ditto "$bundle_path" "$package_path/$bundle_relative_path"
|
||||
printf '%s\n' "$fingerprint" > "$state_path"
|
||||
synced_count=$((synced_count + 1))
|
||||
package_synced=1
|
||||
else
|
||||
skipped_count=$((skipped_count + 1))
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_INSTALL" != "1" ]]; then
|
||||
install_path="$INSTALL_DIR/$plugin_id.mactoolsplugin"
|
||||
if [[ "$package_synced" == "1" || ! -d "$install_path" ]]; then
|
||||
copy_package_to_installed_store "$package_path" "$plugin_id"
|
||||
installed_count=$((installed_count + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
packages+=("$package_path")
|
||||
done < <(discover_plugin_records)
|
||||
|
||||
plugin_filter_count=0
|
||||
for _plugin_filter in "${PLUGIN_FILTERS[@]-}"; do
|
||||
plugin_filter_count=$((plugin_filter_count + 1))
|
||||
done
|
||||
|
||||
if [[ ${#packages[@]} -eq 0 ]]; then
|
||||
if [[ "$plugin_filter_count" -gt 0 ]]; then
|
||||
echo "No plugin matched requested filters in $SOURCE_DIR: ${PLUGIN_FILTERS[*]-}" >&2
|
||||
else
|
||||
echo "No plugins found in $SOURCE_DIR." >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
catalog_args=()
|
||||
for package in "${packages[@]}"; do
|
||||
catalog_args+=(--package "$package")
|
||||
done
|
||||
|
||||
if [[ "$synced_count" -gt 0 || ! -f "$CATALOG_PATH" ]]; then
|
||||
"$REPO_ROOT/scripts/plugins/generate-plugin-catalog.sh" \
|
||||
--mode debug \
|
||||
--output "$CATALOG_PATH" \
|
||||
"${catalog_args[@]}"
|
||||
fi
|
||||
|
||||
echo "Synced $synced_count changed debug plugin package(s); skipped $skipped_count unchanged."
|
||||
echo "Catalog: $CATALOG_PATH"
|
||||
if [[ "$SKIP_INSTALL" != "1" ]]; then
|
||||
echo "Installed $installed_count debug plugin package(s)."
|
||||
echo "Installed store: $INSTALL_DIR"
|
||||
fi
|
||||
Reference in New Issue
Block a user