Files
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

317 lines
9.7 KiB
Bash
Executable File

#!/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