diff --git a/.github/scripts/update-registry.py b/.github/scripts/update-registry.py new file mode 100755 index 000000000..1990df132 --- /dev/null +++ b/.github/scripts/update-registry.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +""" +Update a TablePro plugin registry manifest (plugins.json) with a new binary set. + +For a given plugin ID and PluginKit version, this script: + - Adds or replaces the arm64 and x86_64 binaries for that PluginKit version + - Preserves binaries for other PluginKit versions (up to --keep-kit-versions) + - Drops binaries for PluginKit versions older than (newest - keep + 1) + - Updates plugin-level metadata (name, summary, etc.) from the newest binary set + - Sets schemaVersion to 2 + - Writes atomically via a temp file + rename +""" + +import argparse +import json +import os +import sys +import tempfile + + +def parse_args(): + parser = argparse.ArgumentParser(description="Update plugins.json registry entry") + parser.add_argument("--manifest", required=True) + parser.add_argument("--id", required=True) + parser.add_argument("--name", required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--summary", required=True) + parser.add_argument("--db-type-ids", required=True) + parser.add_argument("--arm64-url", required=True) + parser.add_argument("--arm64-sha", required=True) + parser.add_argument("--x86_64-url", required=True) + parser.add_argument("--x86_64-sha", required=True) + parser.add_argument("--min-app-version", required=True) + parser.add_argument("--icon", required=True) + parser.add_argument("--homepage", required=True) + parser.add_argument("--category", default="database-driver") + parser.add_argument("--plugin-kit-version", required=True, type=int) + parser.add_argument( + "--keep-kit-versions", + default=2, + type=int, + help="Number of distinct PluginKit versions to retain per plugin. Oldest dropped first.", + ) + return parser.parse_args() + + +def load_manifest(path): + with open(path, "r", encoding="utf-8") as file: + return json.load(file) + + +def write_manifest_atomic(path, manifest): + dir_path = os.path.dirname(os.path.abspath(path)) + fd, tmp_path = tempfile.mkstemp(dir=dir_path, suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as file: + json.dump(manifest, file, indent=2) + file.write("\n") + os.replace(tmp_path, path) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + +def prune_old_kit_versions(binaries, keep_count): + versions_seen = [] + for binary in binaries: + pkv = binary.get("pluginKitVersion", 0) + if pkv not in versions_seen: + versions_seen.append(pkv) + + versions_seen.sort(reverse=True) + versions_to_keep = set(versions_seen[:keep_count]) + + return [b for b in binaries if b.get("pluginKitVersion", 0) in versions_to_keep] + + +def update_plugin_entry(manifest, args): + bundle_id = args.id + db_type_ids = json.loads(args.db_type_ids) + pkv = args.plugin_kit_version + + new_binaries = [ + { + "architecture": "arm64", + "pluginKitVersion": pkv, + "downloadURL": args.arm64_url, + "sha256": args.arm64_sha, + }, + { + "architecture": "x86_64", + "pluginKitVersion": pkv, + "downloadURL": args.x86_64_url, + "sha256": args.x86_64_sha, + }, + ] + + existing_plugins = manifest.get("plugins", []) + existing_entry = next((p for p in existing_plugins if p["id"] == bundle_id), None) + + if existing_entry is not None: + surviving = [ + b for b in existing_entry.get("binaries", []) + if b.get("pluginKitVersion", 0) != pkv + ] + merged_binaries = surviving + new_binaries + else: + merged_binaries = new_binaries + + merged_binaries = prune_old_kit_versions(merged_binaries, args.keep_kit_versions) + + updated_entry = { + "id": bundle_id, + "name": args.name, + "version": args.version, + "summary": args.summary, + "author": {"name": "TablePro", "url": "https://tablepro.app"}, + "homepage": args.homepage, + "category": args.category, + "databaseTypeIds": db_type_ids, + "iconName": args.icon, + "isVerified": True, + "minAppVersion": args.min_app_version, + "binaries": merged_binaries, + } + + if existing_entry is not None and existing_entry.get("metadata"): + updated_entry["metadata"] = existing_entry["metadata"] + + manifest["plugins"] = [p for p in existing_plugins if p["id"] != bundle_id] + manifest["plugins"].append(updated_entry) + manifest["schemaVersion"] = 2 + + return manifest + + +def main(): + args = parse_args() + + if not os.path.exists(args.manifest): + print(f"ERROR: manifest not found: {args.manifest}", file=sys.stderr) + sys.exit(1) + + manifest = load_manifest(args.manifest) + manifest = update_plugin_entry(manifest, args) + write_manifest_atomic(args.manifest, manifest) + + entry = next(p for p in manifest["plugins"] if p["id"] == args.id) + kept_versions = sorted( + {b.get("pluginKitVersion", 0) for b in entry["binaries"]}, + reverse=True, + ) + print( + f"Updated {args.id} v{args.version} (PluginKit {args.plugin_kit_version}). " + f"Retained PluginKit versions: {kept_versions}" + ) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml index bbb879a01..1210374cb 100644 --- a/.github/workflows/build-plugin.yml +++ b/.github/workflows/build-plugin.yml @@ -6,7 +6,10 @@ on: workflow_dispatch: inputs: tags: - description: "Plugin tags, comma-separated (e.g., plugin-oracle-v1.0.1,plugin-sqlite-v1.0.1)" + description: > + Comma-separated plugin tag:pluginKitVersion pairs. + pluginKitVersion defaults to currentPluginKitVersion from PluginManager.swift. + Examples: plugin-mongodb-v1.0.25, plugin-mongodb-v1.0.25:13 required: true type: string @@ -22,27 +25,52 @@ jobs: runs-on: ubuntu-latest outputs: matrix: ${{ steps.tags.outputs.matrix }} + currentPluginKitVersion: ${{ steps.pkv.outputs.version }} steps: - - id: tags + - uses: actions/checkout@v4 + + - name: Read currentPluginKitVersion + id: pkv + run: | + VERSION=$(grep -E 'static let currentPluginKitVersion\s*=\s*[0-9]+' \ + TablePro/Core/Plugins/PluginManager.swift \ + | grep -oE '[0-9]+$' | head -1) + if [ -z "$VERSION" ]; then + echo "::error::Could not parse currentPluginKitVersion from PluginManager.swift" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "currentPluginKitVersion=$VERSION" + + - name: Build matrix + id: tags + env: + DEFAULT_PKV: ${{ steps.pkv.outputs.version }} run: | if [ -n "${{ inputs.tags }}" ]; then - IFS=',' read -ra TAGS <<< "${{ inputs.tags }}" + IFS=',' read -ra RAW_TAGS <<< "${{ inputs.tags }}" else - TAGS=("${{ github.ref_name }}") + RAW_TAGS=("${{ github.ref_name }}") fi + JSON='{"include":[' FIRST=true - for TAG in "${TAGS[@]}"; do - TAG=$(echo "$TAG" | xargs) + for ITEM in "${RAW_TAGS[@]}"; do + ITEM=$(echo "$ITEM" | xargs) + TAG=$(echo "$ITEM" | cut -d: -f1) + PKV=$(echo "$ITEM" | cut -d: -f2 -s) + if [ -z "$PKV" ]; then + PKV="$DEFAULT_PKV" + fi if [ "$FIRST" = true ]; then FIRST=false; else JSON+=','; fi - JSON+="{\"tag\":\"$TAG\"}" + JSON+="{\"tag\":\"$TAG\",\"pluginKitVersion\":$PKV}" done JSON+=']}' echo "matrix=$JSON" >> "$GITHUB_OUTPUT" echo "Matrix: $JSON" build-plugin: - name: "Build ${{ matrix.tag }}" + name: "Build ${{ matrix.tag }} (PluginKit ${{ matrix.pluginKitVersion }})" needs: resolve-tags runs-on: macos-26 timeout-minutes: 30 @@ -95,148 +123,168 @@ jobs: --team-id "$APPLE_TEAM_ID" \ --password "$NOTARY_PASSWORD" - - name: Build and release plugin - env: - REGISTRY_DEPLOY_KEY: ${{ secrets.REGISTRY_DEPLOY_KEY }} - GH_TOKEN: ${{ github.token }} + - name: Resolve plugin info + id: plugin run: | TAG="${{ matrix.tag }}" - echo "Processing: $TAG" - - # Get current app version for minAppVersion - MIN_APP_VERSION=$(sed -n 's/.*MARKETING_VERSION = \(.*\);/\1/p' \ - TablePro.xcodeproj/project.pbxproj | head -1 | tr -d ' ') - - resolve_plugin_info() { - local plugin_name=$1 - case "$plugin_name" in - oracle) - TARGET="OracleDriver"; BUNDLE_ID="com.TablePro.OracleDriver" - DISPLAY_NAME="Oracle Driver"; SUMMARY="Oracle Database 12c+ driver via OracleNIO" - DB_TYPE_IDS='["Oracle"]'; ICON="server.rack"; BUNDLE_NAME="OracleDriver" - CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/oracle" ;; - clickhouse) - TARGET="ClickHouseDriver"; BUNDLE_ID="com.TablePro.ClickHouseDriver" - DISPLAY_NAME="ClickHouse Driver"; SUMMARY="ClickHouse OLAP database driver via HTTP interface" - DB_TYPE_IDS='["ClickHouse"]'; ICON="chart.bar.xaxis"; BUNDLE_NAME="ClickHouseDriver" - CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/clickhouse" ;; - sqlite) - TARGET="SQLiteDriver"; BUNDLE_ID="com.TablePro.SQLiteDriver" - DISPLAY_NAME="SQLite Driver"; SUMMARY="SQLite embedded database driver" - DB_TYPE_IDS='["SQLite"]'; ICON="internaldrive"; BUNDLE_NAME="SQLiteDriver" - CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/sqlite" ;; - duckdb) - TARGET="DuckDBDriver"; BUNDLE_ID="com.TablePro.DuckDBDriver" - DISPLAY_NAME="DuckDB Driver"; SUMMARY="DuckDB analytical database driver" - DB_TYPE_IDS='["DuckDB"]'; ICON="bird"; BUNDLE_NAME="DuckDBDriver" - CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/duckdb" ;; - cassandra) - TARGET="CassandraDriver"; BUNDLE_ID="com.TablePro.CassandraDriver" - DISPLAY_NAME="Cassandra Driver"; SUMMARY="Apache Cassandra and ScyllaDB driver via DataStax C driver" - DB_TYPE_IDS='["Cassandra", "ScyllaDB"]'; ICON="cassandra-icon"; BUNDLE_NAME="CassandraDriver" - CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/cassandra" ;; - etcd) - TARGET="EtcdDriverPlugin"; BUNDLE_ID="com.TablePro.EtcdDriverPlugin" - DISPLAY_NAME="etcd Driver"; SUMMARY="etcd v3 key-value store driver with prefix-tree browsing and lease management" - DB_TYPE_IDS='["etcd"]'; ICON="etcd-icon"; BUNDLE_NAME="EtcdDriverPlugin" - CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/etcd" ;; - mssql) - TARGET="MSSQLDriver"; BUNDLE_ID="com.TablePro.MSSQLDriver" - DISPLAY_NAME="MSSQL Driver"; SUMMARY="Microsoft SQL Server driver via FreeTDS" - DB_TYPE_IDS='["SQL Server"]'; ICON="mssql-icon"; BUNDLE_NAME="MSSQLDriver" - CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/mssql" ;; - mongodb) - TARGET="MongoDBDriver"; BUNDLE_ID="com.TablePro.MongoDBDriver" - DISPLAY_NAME="MongoDB Driver"; SUMMARY="MongoDB document database driver via libmongoc" - DB_TYPE_IDS='["MongoDB"]'; ICON="mongodb-icon"; BUNDLE_NAME="MongoDBDriver" - CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/mongodb" ;; - redis) - TARGET="RedisDriver"; BUNDLE_ID="com.TablePro.RedisDriver" - DISPLAY_NAME="Redis Driver"; SUMMARY="Redis in-memory data store driver via hiredis" - DB_TYPE_IDS='["Redis"]'; ICON="redis-icon"; BUNDLE_NAME="RedisDriver" - CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/redis" ;; - cloudflare-d1) - TARGET="CloudflareD1DriverPlugin"; BUNDLE_ID="com.TablePro.CloudflareD1DriverPlugin" - DISPLAY_NAME="Cloudflare D1 Driver"; SUMMARY="Cloudflare D1 serverless SQLite-compatible database driver via REST API" - DB_TYPE_IDS='["Cloudflare D1"]'; ICON="cloudflare-d1-icon"; BUNDLE_NAME="CloudflareD1DriverPlugin" - CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/cloudflare-d1" ;; - libsql) - TARGET="LibSQLDriverPlugin"; BUNDLE_ID="com.TablePro.LibSQLDriverPlugin" - DISPLAY_NAME="libSQL / Turso Driver"; SUMMARY="libSQL and Turso database support via Hrana HTTP protocol" - DB_TYPE_IDS='["libSQL","Turso"]'; ICON="libsql-icon"; BUNDLE_NAME="LibSQLDriverPlugin" - CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/libsql" ;; - dynamodb) - TARGET="DynamoDBDriverPlugin"; BUNDLE_ID="com.TablePro.DynamoDBDriverPlugin" - DISPLAY_NAME="DynamoDB Driver"; SUMMARY="Amazon DynamoDB driver with PartiQL queries and AWS IAM/Profile/SSO authentication" - DB_TYPE_IDS='["DynamoDB"]'; ICON="dynamodb-icon"; BUNDLE_NAME="DynamoDBDriverPlugin" - CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/dynamodb" ;; - bigquery) - TARGET="BigQueryDriverPlugin"; BUNDLE_ID="com.TablePro.BigQueryDriverPlugin" - DISPLAY_NAME="BigQuery Driver"; SUMMARY="Google BigQuery analytics database driver via REST API" - DB_TYPE_IDS='["BigQuery"]'; ICON="bigquery-icon"; BUNDLE_NAME="BigQueryDriverPlugin" - CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/bigquery" ;; - xlsx) - TARGET="XLSXExport"; BUNDLE_ID="com.TablePro.XLSXExportPlugin" - DISPLAY_NAME="XLSX Export"; SUMMARY="Export data to Microsoft Excel XLSX format" - DB_TYPE_IDS='null'; ICON="doc.richtext"; BUNDLE_NAME="XLSXExport" - CATEGORY="export-format"; HOMEPAGE="https://docs.tablepro.app/features/export" ;; - mql) - TARGET="MQLExport"; BUNDLE_ID="com.TablePro.MQLExportPlugin" - DISPLAY_NAME="MQL Export"; SUMMARY="Export MongoDB data as MQL statements" - DB_TYPE_IDS='null'; ICON="doc.text"; BUNDLE_NAME="MQLExport" - CATEGORY="export-format"; HOMEPAGE="https://docs.tablepro.app/features/export" ;; - sqlimport) - TARGET="SQLImport"; BUNDLE_ID="com.TablePro.SQLImportPlugin" - DISPLAY_NAME="SQL Import"; SUMMARY="Import data from SQL dump files" - DB_TYPE_IDS='null'; ICON="square.and.arrow.down"; BUNDLE_NAME="SQLImport" - CATEGORY="import-format"; HOMEPAGE="https://docs.tablepro.app/features/import" ;; - *) echo "Unknown plugin: $plugin_name"; return 1 ;; - esac - } - PLUGIN_NAME=$(echo "$TAG" | sed -E 's/^plugin-([a-z0-9-]+)-v([0-9].*)$/\1/') VERSION=$(echo "$TAG" | sed -E 's/^plugin-([a-z0-9-]+)-v([0-9].*)$/\2/') - resolve_plugin_info "$PLUGIN_NAME" - - echo "Building $TARGET v$VERSION" - - # Build Cassandra dependencies if needed - if [ "$PLUGIN_NAME" = "cassandra" ]; then - ./scripts/build-cassandra.sh both - fi - - # Build both architectures (pass VERSION so the built bundle's - # CFBundleShortVersionString matches the registry version) - ./scripts/build-plugin.sh "$TARGET" arm64 "$VERSION" - ./scripts/build-plugin.sh "$TARGET" x86_64 "$VERSION" + case "$PLUGIN_NAME" in + oracle) + TARGET="OracleDriver"; BUNDLE_ID="com.TablePro.OracleDriver" + DISPLAY_NAME="Oracle Driver"; SUMMARY="Oracle Database 12c+ driver via OracleNIO" + DB_TYPE_IDS='["Oracle"]'; ICON="server.rack"; BUNDLE_NAME="OracleDriver" + CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/oracle" ;; + clickhouse) + TARGET="ClickHouseDriver"; BUNDLE_ID="com.TablePro.ClickHouseDriver" + DISPLAY_NAME="ClickHouse Driver"; SUMMARY="ClickHouse OLAP database driver via HTTP interface" + DB_TYPE_IDS='["ClickHouse"]'; ICON="chart.bar.xaxis"; BUNDLE_NAME="ClickHouseDriver" + CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/clickhouse" ;; + sqlite) + TARGET="SQLiteDriver"; BUNDLE_ID="com.TablePro.SQLiteDriver" + DISPLAY_NAME="SQLite Driver"; SUMMARY="SQLite embedded database driver" + DB_TYPE_IDS='["SQLite"]'; ICON="internaldrive"; BUNDLE_NAME="SQLiteDriver" + CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/sqlite" ;; + duckdb) + TARGET="DuckDBDriver"; BUNDLE_ID="com.TablePro.DuckDBDriver" + DISPLAY_NAME="DuckDB Driver"; SUMMARY="DuckDB analytical database driver" + DB_TYPE_IDS='["DuckDB"]'; ICON="bird"; BUNDLE_NAME="DuckDBDriver" + CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/duckdb" ;; + cassandra) + TARGET="CassandraDriver"; BUNDLE_ID="com.TablePro.CassandraDriver" + DISPLAY_NAME="Cassandra Driver"; SUMMARY="Apache Cassandra and ScyllaDB driver via DataStax C driver" + DB_TYPE_IDS='["Cassandra","ScyllaDB"]'; ICON="cassandra-icon"; BUNDLE_NAME="CassandraDriver" + CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/cassandra" ;; + etcd) + TARGET="EtcdDriverPlugin"; BUNDLE_ID="com.TablePro.EtcdDriverPlugin" + DISPLAY_NAME="etcd Driver"; SUMMARY="etcd v3 key-value store driver with prefix-tree browsing and lease management" + DB_TYPE_IDS='["etcd"]'; ICON="etcd-icon"; BUNDLE_NAME="EtcdDriverPlugin" + CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/etcd" ;; + mssql) + TARGET="MSSQLDriver"; BUNDLE_ID="com.TablePro.MSSQLDriver" + DISPLAY_NAME="MSSQL Driver"; SUMMARY="Microsoft SQL Server driver via FreeTDS" + DB_TYPE_IDS='["SQL Server"]'; ICON="mssql-icon"; BUNDLE_NAME="MSSQLDriver" + CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/mssql" ;; + mongodb) + TARGET="MongoDBDriver"; BUNDLE_ID="com.TablePro.MongoDBDriver" + DISPLAY_NAME="MongoDB Driver"; SUMMARY="MongoDB document database driver via libmongoc" + DB_TYPE_IDS='["MongoDB"]'; ICON="mongodb-icon"; BUNDLE_NAME="MongoDBDriver" + CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/mongodb" ;; + redis) + TARGET="RedisDriver"; BUNDLE_ID="com.TablePro.RedisDriver" + DISPLAY_NAME="Redis Driver"; SUMMARY="Redis in-memory data store driver via hiredis" + DB_TYPE_IDS='["Redis"]'; ICON="redis-icon"; BUNDLE_NAME="RedisDriver" + CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/redis" ;; + cloudflare-d1) + TARGET="CloudflareD1DriverPlugin"; BUNDLE_ID="com.TablePro.CloudflareD1DriverPlugin" + DISPLAY_NAME="Cloudflare D1 Driver"; SUMMARY="Cloudflare D1 serverless SQLite-compatible database driver via REST API" + DB_TYPE_IDS='["Cloudflare D1"]'; ICON="cloudflare-d1-icon"; BUNDLE_NAME="CloudflareD1DriverPlugin" + CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/cloudflare-d1" ;; + libsql) + TARGET="LibSQLDriverPlugin"; BUNDLE_ID="com.TablePro.LibSQLDriverPlugin" + DISPLAY_NAME="libSQL / Turso Driver"; SUMMARY="libSQL and Turso database support via Hrana HTTP protocol" + DB_TYPE_IDS='["libSQL","Turso"]'; ICON="libsql-icon"; BUNDLE_NAME="LibSQLDriverPlugin" + CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/libsql" ;; + dynamodb) + TARGET="DynamoDBDriverPlugin"; BUNDLE_ID="com.TablePro.DynamoDBDriverPlugin" + DISPLAY_NAME="DynamoDB Driver"; SUMMARY="Amazon DynamoDB driver with PartiQL queries and AWS IAM/Profile/SSO authentication" + DB_TYPE_IDS='["DynamoDB"]'; ICON="dynamodb-icon"; BUNDLE_NAME="DynamoDBDriverPlugin" + CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/dynamodb" ;; + bigquery) + TARGET="BigQueryDriverPlugin"; BUNDLE_ID="com.TablePro.BigQueryDriverPlugin" + DISPLAY_NAME="BigQuery Driver"; SUMMARY="Google BigQuery analytics database driver via REST API" + DB_TYPE_IDS='["BigQuery"]'; ICON="bigquery-icon"; BUNDLE_NAME="BigQueryDriverPlugin" + CATEGORY="database-driver"; HOMEPAGE="https://docs.tablepro.app/databases/bigquery" ;; + xlsx) + TARGET="XLSXExport"; BUNDLE_ID="com.TablePro.XLSXExportPlugin" + DISPLAY_NAME="XLSX Export"; SUMMARY="Export data to Microsoft Excel XLSX format" + DB_TYPE_IDS='null'; ICON="doc.richtext"; BUNDLE_NAME="XLSXExport" + CATEGORY="export-format"; HOMEPAGE="https://docs.tablepro.app/features/export" ;; + mql) + TARGET="MQLExport"; BUNDLE_ID="com.TablePro.MQLExportPlugin" + DISPLAY_NAME="MQL Export"; SUMMARY="Export MongoDB data as MQL statements" + DB_TYPE_IDS='null'; ICON="doc.text"; BUNDLE_NAME="MQLExport" + CATEGORY="export-format"; HOMEPAGE="https://docs.tablepro.app/features/export" ;; + sqlimport) + TARGET="SQLImport"; BUNDLE_ID="com.TablePro.SQLImportPlugin" + DISPLAY_NAME="SQL Import"; SUMMARY="Import data from SQL dump files" + DB_TYPE_IDS='null'; ICON="square.and.arrow.down"; BUNDLE_NAME="SQLImport" + CATEGORY="import-format"; HOMEPAGE="https://docs.tablepro.app/features/import" ;; + *) + echo "::error::Unknown plugin name: $PLUGIN_NAME" + exit 1 ;; + esac + + MIN_APP_VERSION=$(grep -E 'MARKETING_VERSION\s*=\s*[0-9]' \ + TablePro.xcodeproj/project.pbxproj | head -1 \ + | sed 's/.*MARKETING_VERSION = \(.*\);/\1/' | tr -d ' ') + + { + echo "target=$TARGET" + echo "bundleId=$BUNDLE_ID" + echo "displayName=$DISPLAY_NAME" + echo "summary=$SUMMARY" + echo "dbTypeIds=$DB_TYPE_IDS" + echo "icon=$ICON" + echo "bundleName=$BUNDLE_NAME" + echo "category=$CATEGORY" + echo "homepage=$HOMEPAGE" + echo "version=$VERSION" + echo "minAppVersion=$MIN_APP_VERSION" + } >> "$GITHUB_OUTPUT" + + - name: Build Cassandra dependencies + if: ${{ contains(matrix.tag, 'plugin-cassandra-') }} + run: ./scripts/build-cassandra.sh both + + - name: Build plugin binaries + run: | + ./scripts/build-plugin.sh "${{ steps.plugin.outputs.target }}" arm64 "${{ steps.plugin.outputs.version }}" + ./scripts/build-plugin.sh "${{ steps.plugin.outputs.target }}" x86_64 "${{ steps.plugin.outputs.version }}" - # Capture SHA-256 + - name: Read checksums + id: sha + run: | + BUNDLE_NAME="${{ steps.plugin.outputs.bundleName }}" ARM64_SHA=$(cat "build/Plugins/${BUNDLE_NAME}-arm64.zip.sha256") X86_SHA=$(cat "build/Plugins/${BUNDLE_NAME}-x86_64.zip.sha256") + { + echo "arm64=$ARM64_SHA" + echo "x86_64=$X86_SHA" + } >> "$GITHUB_OUTPUT" - # Notarize if enabled - if [ "${NOTARIZE_PLUGINS:-}" = "true" ]; then - for zip in build/Plugins/${BUNDLE_NAME}-*.zip; do - xcrun notarytool submit "$zip" \ - --keychain-profile "TablePro" \ - --wait - done - fi + - name: Notarize + if: ${{ env.NOTARIZE_PLUGINS == 'true' }} + run: | + BUNDLE_NAME="${{ steps.plugin.outputs.bundleName }}" + for zip in build/Plugins/${BUNDLE_NAME}-*.zip; do + xcrun notarytool submit "$zip" --keychain-profile "TablePro" --wait + done + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${{ matrix.tag }}" + DISPLAY_NAME="${{ steps.plugin.outputs.displayName }}" + VERSION="${{ steps.plugin.outputs.version }}" + BUNDLE_NAME="${{ steps.plugin.outputs.bundleName }}" + ARM64_SHA="${{ steps.sha.outputs.arm64 }}" + X86_SHA="${{ steps.sha.outputs.x86_64 }}" + PKV="${{ matrix.pluginKitVersion }}" - # Create GitHub Release RELEASE_BODY="## $DISPLAY_NAME v$VERSION - Plugin release for TablePro. +Plugin release for TablePro (PluginKit $PKV). - ### Installation - TablePro will prompt you to install this plugin automatically when you select the database type. You can also install manually via **Settings > Plugins > Browse**. +### Installation +TablePro will prompt you to install this plugin automatically when you select the database type. You can also install manually via Settings > Plugins > Browse. - ### SHA-256 - - ARM64: \`$ARM64_SHA\` - - x86_64: \`$X86_SHA\`" +### SHA-256 +- ARM64: \`$ARM64_SHA\` +- x86_64: \`$X86_SHA\`" - # Delete existing release if any, then create gh release delete "$TAG" --yes 2>/dev/null || true gh release create "$TAG" \ --title "$DISPLAY_NAME v$VERSION" \ @@ -244,94 +292,82 @@ jobs: build/Plugins/${BUNDLE_NAME}-arm64.zip \ build/Plugins/${BUNDLE_NAME}-x86_64.zip - # Update plugin registry (with retry to handle parallel pushes) - if [ -n "${REGISTRY_DEPLOY_KEY:-}" ]; then - ARM64_URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/${BUNDLE_NAME}-arm64.zip" - X86_64_URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/${BUNDLE_NAME}-x86_64.zip" - - WORK=$(mktemp -d) - eval "$(ssh-agent -s)" - echo "$REGISTRY_DEPLOY_KEY" | ssh-add - - - git clone git@github.com:TableProApp/plugins.git "$WORK/registry" - cd "$WORK/registry" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - # Retry loop: pull latest, apply update, push — retry if another job pushed first - MAX_RETRIES=10 - for attempt in $(seq 1 $MAX_RETRIES); do - echo "Registry update attempt $attempt/$MAX_RETRIES" - - # Reset any previous commit and pull latest - git reset --hard origin/main - git pull --rebase origin main - - python3 - \ - "$BUNDLE_ID" "$DISPLAY_NAME" "$VERSION" "$SUMMARY" \ - "$DB_TYPE_IDS" "$ARM64_URL" "$ARM64_SHA" \ - "$X86_64_URL" "$X86_SHA" "$MIN_APP_VERSION" \ - "$ICON" "$HOMEPAGE" "$CATEGORY" \ - <<'PYTHON_SCRIPT' - import json, sys - - bundle_id, name, version, summary = sys.argv[1:5] - db_type_ids = json.loads(sys.argv[5]) - arm64_url, arm64_sha = sys.argv[6], sys.argv[7] - x86_64_url, x86_64_sha = sys.argv[8], sys.argv[9] - min_app_version, icon, homepage = sys.argv[10], sys.argv[11], sys.argv[12] - category = sys.argv[13] if len(sys.argv) > 13 else "database-driver" - - with open("plugins.json", "r") as f: - manifest = json.load(f) - - entry = { - "id": bundle_id, "name": name, "version": version, - "summary": summary, - "author": {"name": "TablePro", "url": "https://tablepro.app"}, - "homepage": homepage, "category": category, - "databaseTypeIds": db_type_ids, - "downloadURL": arm64_url, "sha256": arm64_sha, - "binaries": [ - {"architecture": "arm64", "downloadURL": arm64_url, "sha256": arm64_sha}, - {"architecture": "x86_64", "downloadURL": x86_64_url, "sha256": x86_64_sha} - ], - "minAppVersion": min_app_version, - "minPluginKitVersion": 2, - "iconName": icon, "isVerified": True - } - - manifest["plugins"] = [p for p in manifest["plugins"] if p["id"] != bundle_id] - manifest["plugins"].append(entry) - - with open("plugins.json", "w") as f: - json.dump(manifest, f, indent=2) - f.write("\n") - PYTHON_SCRIPT - - git add plugins.json - git commit -m "Update $DISPLAY_NAME to v$VERSION" - - if git push; then - echo "Registry updated successfully on attempt $attempt" - break - fi - - if [ "$attempt" -eq "$MAX_RETRIES" ]; then - echo "::error::Failed to push registry update after $MAX_RETRIES attempts" - exit 1 - fi - - # Jittered backoff: 2-5s base + random to spread parallel retries - DELAY=$((2 + RANDOM % 4)) - echo "Push rejected (concurrent update), retrying in ${DELAY}s..." - sleep "$DELAY" - done - - ssh-add -D - eval "$(ssh-agent -k)" - cd - - rm -rf "$WORK" - fi + - name: Update plugin registry + if: ${{ env.REGISTRY_DEPLOY_KEY != '' }} + env: + REGISTRY_DEPLOY_KEY: ${{ secrets.REGISTRY_DEPLOY_KEY }} + GH_TOKEN: ${{ github.token }} + run: | + TAG="${{ matrix.tag }}" + BUNDLE_NAME="${{ steps.plugin.outputs.bundleName }}" + BUNDLE_ID="${{ steps.plugin.outputs.bundleId }}" + DISPLAY_NAME="${{ steps.plugin.outputs.displayName }}" + VERSION="${{ steps.plugin.outputs.version }}" + SUMMARY="${{ steps.plugin.outputs.summary }}" + DB_TYPE_IDS='${{ steps.plugin.outputs.dbTypeIds }}' + MIN_APP_VERSION="${{ steps.plugin.outputs.minAppVersion }}" + ICON="${{ steps.plugin.outputs.icon }}" + HOMEPAGE="${{ steps.plugin.outputs.homepage }}" + CATEGORY="${{ steps.plugin.outputs.category }}" + ARM64_SHA="${{ steps.sha.outputs.arm64 }}" + X86_SHA="${{ steps.sha.outputs.x86_64 }}" + PKV="${{ matrix.pluginKitVersion }}" + REPO="${{ github.repository }}" + ARM64_URL="https://github.com/${REPO}/releases/download/${TAG}/${BUNDLE_NAME}-arm64.zip" + X86_64_URL="https://github.com/${REPO}/releases/download/${TAG}/${BUNDLE_NAME}-x86_64.zip" + + SCRIPT_PATH="$(pwd)/.github/scripts/update-registry.py" + + WORK=$(mktemp -d) + eval "$(ssh-agent -s)" + trap 'ssh-add -D 2>/dev/null || true; eval "$(ssh-agent -k)" 2>/dev/null || true' EXIT + echo "$REGISTRY_DEPLOY_KEY" | ssh-add - + + git clone git@github.com:TableProApp/plugins.git "$WORK/registry" + cd "$WORK/registry" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + MAX_RETRIES=10 + for attempt in $(seq 1 $MAX_RETRIES); do + echo "Registry update attempt $attempt/$MAX_RETRIES" + git reset --hard origin/main + git pull --rebase origin main + + python3 "$SCRIPT_PATH" \ + --manifest plugins.json \ + --id "$BUNDLE_ID" \ + --name "$DISPLAY_NAME" \ + --version "$VERSION" \ + --summary "$SUMMARY" \ + --db-type-ids "$DB_TYPE_IDS" \ + --arm64-url "$ARM64_URL" \ + --arm64-sha "$ARM64_SHA" \ + --x86_64-url "$X86_64_URL" \ + --x86_64-sha "$X86_SHA" \ + --min-app-version "$MIN_APP_VERSION" \ + --icon "$ICON" \ + --homepage "$HOMEPAGE" \ + --category "$CATEGORY" \ + --plugin-kit-version "$PKV" \ + --keep-kit-versions 2 + + git add plugins.json + git commit -m "Update $DISPLAY_NAME to v$VERSION (PluginKit $PKV)" + + if git push; then + echo "Registry updated on attempt $attempt" + break + fi + + if [ "$attempt" -eq "$MAX_RETRIES" ]; then + echo "::error::Failed to push registry update after $MAX_RETRIES attempts" + exit 1 + fi + + DELAY=$((2 + RANDOM % 4)) + echo "Push rejected (concurrent update), retrying in ${DELAY}s..." + sleep "$DELAY" + done - echo "$DISPLAY_NAME v$VERSION released" + echo "$DISPLAY_NAME v$VERSION released (PluginKit $PKV)" diff --git a/.gitignore b/.gitignore index c525d8208..2c7b0f3e8 100644 --- a/.gitignore +++ b/.gitignore @@ -150,3 +150,4 @@ Libs/*.a Libs/.downloaded Libs/dylibs/ Libs/ios/ +fix-1322-plugin-abi-and-registry-overhaul.diff diff --git a/CHANGELOG.md b/CHANGELOG.md index b46a4c98f..1a526b4a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Right-click a column header to copy all its values from the loaded rows (#1325) - Copy as submenu on the row context menu now offers CSV, CSV with Headers, Markdown table, and IN Clause for SQL `WHERE id IN (...)` lookups (#1325) +- Plugin updates that arrive while a connection is open stage on disk and apply when you close the connection or quit, instead of blocking the update +- Settings > Plugins shows a badge with the count of rejected plugins plus available updates so you can see at a glance when attention is needed +- Connections whose driver plugin failed to load show a yellow triangle in the welcome list +- Rejected driver plugins now show an inline banner with an Update Plugin button inside the connection form + +### Changed + +- Plugin registry schema bumped to v2 with per-binary `pluginKitVersion`, so the app picks the binary built for its ABI even when newer or older binaries coexist in the registry (#1322) +- Plugin install pipeline rewritten around a `PluginInstaller` actor with per-plugin coalescing, atomic install via `FileManager.replaceItem`, and `com.apple.quarantine` xattr stripping after extract +- Auto-update now runs as a reconciliation loop after initial load with backoff (immediate, 30 s, 5 min) instead of a single best-effort pass, and works for every rejected plugin regardless of lazy or eager load path (#1322) +- Plugin rejection no longer interrupts launch with a modal alert; the app posts a UserNotifications banner and surfaces rejected entries inline in Settings > Plugins with Update Now and Remove buttons +- CI workflow `build-plugin.yml` accepts `tag:pluginKitVersion` pairs and reads `currentPluginKitVersion` from `PluginManager.swift`, replacing the hardcoded `minPluginKitVersion: 2` that made registry pre-install checks ineffective +- New `scripts/release-all-plugins.sh` triggers a single workflow run that rebuilds every registry plugin for a given PluginKit version after an ABI bump - Double-click or press Return on a read-only query result cell to open a selectable text viewer in the cell. JSON columns open the JSON viewer in a popover, BLOB columns open the hex viewer. The value is selectable and copyable (#1336) ### Changed @@ -20,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Resolves the recurring "Plugin was built with PluginKit version N, but version M is required" error after app updates (#1322, #1237, #923, #912, #443). Rejected plugins now auto-update from the registry without manual intervention - DuckDB Spatial `GEOMETRY` columns render as WKT, not NULL (#1324) - DuckDB `HUGEINT` and `UHUGEINT` keep full precision and no longer crash on negatives - DuckDB streaming results honor the row cap and render `TIMESTAMPTZ`/`TIMETZ`/`GEOMETRY` instead of NULL diff --git a/CLAUDE.md b/CLAUDE.md index f96aa06a9..027755123 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,11 +98,17 @@ When adding a new method to the driver protocol: add to `PluginDatabaseDriver` ( **PluginKit ABI versioning**: When `DriverPlugin` or `PluginDatabaseDriver` protocol changes (new methods, changed signatures), bump `currentPluginKitVersion` in `PluginManager.swift` AND `TableProPluginKitVersion` in every plugin's `Info.plist`. Stale user-installed plugins with mismatched versions crash on load with `EXC_BAD_INSTRUCTION` (not catchable in Swift). Removing protocol methods that have default `nil` implementations does NOT require a version bump. Adding new `static var` or `func` requirements to `DriverPlugin` DOES require a version bump even with default implementations via protocol extension — Swift protocol witness tables are compiled statically. -**Post-ABI-bump checklist (mandatory)**: After bumping `currentPluginKitVersion`, every registry-published plugin must be re-tagged and republished — otherwise users see "Plugin was built with PluginKit version N, but version M is required" when they try to update. `PluginManager` rejects any user-installed plugin whose `TableProPluginKitVersion` does not match exactly (`PluginManager.swift:387`). -1. Re-tag every registry plugin with a bumped patch version (MongoDB, Oracle, DuckDB, MSSQL, Cassandra, Etcd, CloudflareD1, DynamoDB, BigQuery, LibSQL). Bundled plugins (Redis, ClickHouse, etc.) are NOT re-tagged; they ship with the next app release. Push tags individually because `build-plugin.yml` only fires once per multi-tag push. -2. Wait for CI to publish each ZIP to its `plugin--v` GitHub Release. -3. Update `plugins.json` in [TableProApp/plugins](https://github.com/TableProApp/plugins): bump `version`, `downloadURL`, and both architecture `sha256` entries for every plugin. -4. Verify by installing one plugin from registry on the new app build. +**Post-ABI-bump checklist (mandatory)**: After bumping `currentPluginKitVersion`, every registry-published plugin must be rebuilt against the new ABI. App auto-update reconciliation handles the user-facing recovery, but the registry has to carry binaries for the new PluginKit version first. + +1. Commit the bump (updates `PluginManager.swift` and every bundled plugin's `Info.plist`). Bundled plugins ship with the next app release. Do not tag them. +2. Trigger the bulk re-release: + ```bash + ./scripts/release-all-plugins.sh + ``` + The workflow runs all registry plugins as a parallel matrix, publishes ZIPs to GitHub Releases, and updates `plugins.json` (via `.github/scripts/update-registry.py`, which appends new binaries and prunes per the `--keep-kit-versions 2` policy). No manual `plugins.json` editing. +3. Verify by installing one plugin from the registry on a build with the new PluginKit version. + +**Binary retention policy**: The registry keeps binaries for the two most recent PluginKit versions per plugin (`--keep-kit-versions 2`). Users on the previous app version can still install plugins; users two or more versions behind hit `noCompatibleBinary` and need to update the app. ### DatabaseType (String-Based Struct) @@ -268,6 +274,6 @@ If anything matches, rewrite before committing. GitHub Actions (`.github/workflows/build.yml`) triggered by `v*` tags: lint → build arm64 → build x86_64 → release (DMG/ZIP + Sparkle signatures). Release notes auto-extracted from `CHANGELOG.md`. -**Plugin CI** (`.github/workflows/build-plugin.yml`): triggered by `plugin-*-v*` tags. GitHub only fires one workflow per multi-tag `git push` — push tags individually or use `workflow_dispatch` with comma-separated tags for bulk releases. +**Plugin CI** (`.github/workflows/build-plugin.yml`): triggered by `plugin-*-v*` tags or `workflow_dispatch`. The dispatch input accepts comma-separated `tag:pluginKitVersion` pairs; if `:pluginKitVersion` is omitted, the workflow reads `currentPluginKitVersion` from `PluginManager.swift`. Registry update logic lives in `.github/scripts/update-registry.py` (atomic write, per-binary `pluginKitVersion`, prune-old policy). Use `scripts/release-all-plugins.sh ` for bulk re-release after an ABI bump. **Plugin tag naming**: Tag names must match the CI workflow's `resolve_plugin_info()` mapping. Notable non-obvious mappings: `CloudflareD1DriverPlugin` → `plugin-cloudflare-d1-v*`, `EtcdDriverPlugin` → `plugin-etcd-v*`. Check existing tags with `git tag -l "plugin-*"` before creating new ones. diff --git a/Packages/TableProCore/Sources/TableProModels/DatabaseType.swift b/Packages/TableProCore/Sources/TableProModels/DatabaseType.swift index fd31bab44..4272a587b 100644 --- a/Packages/TableProCore/Sources/TableProModels/DatabaseType.swift +++ b/Packages/TableProCore/Sources/TableProModels/DatabaseType.swift @@ -57,8 +57,6 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { } } - /// Plugin type ID for plugin lookup. - /// Multi-type plugins share a single driver: MariaDB -> "MySQL", Redshift -> "PostgreSQL" public var pluginTypeId: String { switch self { case .mariadb: return DatabaseType.mysql.rawValue diff --git a/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift b/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift index 550b4b706..e4ce4cef8 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift @@ -19,7 +19,6 @@ final class BigQueryPlugin: NSObject, TableProPlugin, DriverPlugin { static let databaseDisplayName = "Google BigQuery" static let iconName = "bigquery-icon" static let defaultPort = 0 - static let additionalDatabaseTypeIds: [String] = [] static let systemSchemaNames: [String] = ["INFORMATION_SCHEMA"] static let isDownloadable = true static let defaultSchemaName = "" diff --git a/Plugins/DynamoDBDriverPlugin/DynamoDBPlugin.swift b/Plugins/DynamoDBDriverPlugin/DynamoDBPlugin.swift index e91faee36..fa84cec47 100644 --- a/Plugins/DynamoDBDriverPlugin/DynamoDBPlugin.swift +++ b/Plugins/DynamoDBDriverPlugin/DynamoDBPlugin.swift @@ -19,7 +19,6 @@ final class DynamoDBPlugin: NSObject, TableProPlugin, DriverPlugin { static let databaseDisplayName = "Amazon DynamoDB" static let iconName = "dynamodb-icon" static let defaultPort = 0 - static let additionalDatabaseTypeIds: [String] = [] static let isDownloadable = true static let connectionMode: ConnectionMode = .apiOnly diff --git a/Plugins/EtcdDriverPlugin/EtcdPlugin.swift b/Plugins/EtcdDriverPlugin/EtcdPlugin.swift index a9c39b45d..22e0307e6 100644 --- a/Plugins/EtcdDriverPlugin/EtcdPlugin.swift +++ b/Plugins/EtcdDriverPlugin/EtcdPlugin.swift @@ -19,7 +19,6 @@ final class EtcdPlugin: NSObject, TableProPlugin, DriverPlugin { static let databaseDisplayName = "etcd" static let iconName = "etcd-icon" static let defaultPort = 2379 - static let additionalDatabaseTypeIds: [String] = [] static let isDownloadable = true static let navigationModel: NavigationModel = .standard diff --git a/Plugins/RedisDriverPlugin/RedisPlugin.swift b/Plugins/RedisDriverPlugin/RedisPlugin.swift index e4ffa17ea..84f781c93 100644 --- a/Plugins/RedisDriverPlugin/RedisPlugin.swift +++ b/Plugins/RedisDriverPlugin/RedisPlugin.swift @@ -36,7 +36,6 @@ final class RedisPlugin: NSObject, TableProPlugin, DriverPlugin { section: .advanced ), ] - static let additionalDatabaseTypeIds: [String] = [] // MARK: - UI/Capability Metadata diff --git a/Plugins/TableProPluginKit/PluginCapabilities.swift b/Plugins/TableProPluginKit/PluginCapabilities.swift index bb9d18d8a..57436c21d 100644 --- a/Plugins/TableProPluginKit/PluginCapabilities.swift +++ b/Plugins/TableProPluginKit/PluginCapabilities.swift @@ -7,6 +7,8 @@ public struct PluginCapabilities: OptionSet, Sendable { self.rawValue = rawValue } + // Bits are ABI-stable: never reuse a bit number for a different meaning. + // Bits 4, 5, 7, 8, 10, 11 are declared but not currently read by the app. public static let materializedViews = PluginCapabilities(rawValue: 1 << 0) public static let foreignTables = PluginCapabilities(rawValue: 1 << 1) public static let storedProcedures = PluginCapabilities(rawValue: 1 << 2) diff --git a/Plugins/TableProPluginKit/PluginTableInfo.swift b/Plugins/TableProPluginKit/PluginTableInfo.swift index 02189aa0e..fdc87b4cf 100644 --- a/Plugins/TableProPluginKit/PluginTableInfo.swift +++ b/Plugins/TableProPluginKit/PluginTableInfo.swift @@ -5,22 +5,16 @@ public struct PluginTableInfo: Codable, Sendable { public let type: String public let rowCount: Int? public let schema: String? - public let owner: String? - public let comment: String? public init( name: String, type: String = "TABLE", rowCount: Int? = nil, - schema: String? = nil, - owner: String? = nil, - comment: String? = nil + schema: String? = nil ) { self.name = name self.type = type self.rowCount = rowCount self.schema = schema - self.owner = owner - self.comment = comment } } diff --git a/Plugins/TableProPluginKit/PluginTableMetadata.swift b/Plugins/TableProPluginKit/PluginTableMetadata.swift index c9f0864d8..0cd007e81 100644 --- a/Plugins/TableProPluginKit/PluginTableMetadata.swift +++ b/Plugins/TableProPluginKit/PluginTableMetadata.swift @@ -5,25 +5,37 @@ public struct PluginTableMetadata: Codable, Sendable { public let dataSize: Int64? public let indexSize: Int64? public let totalSize: Int64? + public let avgRowLength: Int64? public let rowCount: Int64? public let comment: String? public let engine: String? + public let collation: String? + public let createTime: Date? + public let updateTime: Date? public init( tableName: String, dataSize: Int64? = nil, indexSize: Int64? = nil, totalSize: Int64? = nil, + avgRowLength: Int64? = nil, rowCount: Int64? = nil, comment: String? = nil, - engine: String? = nil + engine: String? = nil, + collation: String? = nil, + createTime: Date? = nil, + updateTime: Date? = nil ) { self.tableName = tableName self.dataSize = dataSize self.indexSize = indexSize self.totalSize = totalSize + self.avgRowLength = avgRowLength self.rowCount = rowCount self.comment = comment self.engine = engine + self.collation = collation + self.createTime = createTime + self.updateTime = updateTime } } diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 5e1054d68..51ab77b1c 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -7,6 +7,7 @@ import AppKit import Combine import os import SwiftUI +import UserNotifications @MainActor class AppDelegate: NSObject, NSApplicationDelegate { @@ -14,7 +15,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") private var hasRunPostLaunchActivation = false - private var pluginsRejectedCancellable: AnyCancellable? // MARK: - URL & File Open @@ -61,6 +61,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { MemoryPressureAdvisor.startMonitoring() PluginManager.shared.loadPlugins() + UNUserNotificationCenter.current().delegate = self + PluginNotificationService.shared.setUp() ChatToolBootstrap.register() NSWorkspace.shared.notificationCenter.addObserver( @@ -84,11 +86,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { self, selector: #selector(windowWillClose(_:)), name: NSWindow.willCloseNotification, object: nil ) - pluginsRejectedCancellable = AppEvents.shared.pluginsRejected - .receive(on: RunLoop.main) - .sink { [weak self] rejected in - self?.handlePluginsRejected(rejected) - } } func applicationDidBecomeActive(_ notification: Notification) { @@ -152,42 +149,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - // MARK: - Plugin Rejection Alert - - private func handlePluginsRejected(_ rejected: [RejectedPlugin]) { - guard !rejected.isEmpty else { return } - let details = rejected.map { "\($0.name): \($0.reason)" }.joined(separator: "\n") - Task { - let alert = NSAlert() - alert.messageText = String( - format: String(localized: "%d plugin(s) could not be loaded"), - rejected.count - ) - alert.informativeText = String( - format: String(localized: "The following plugins were rejected:\n\n%@\n\nYou can update them from the plugin registry in Settings."), - details - ) - alert.alertStyle = .warning - alert.addButton(withTitle: String(localized: "Open Plugin Settings")) - alert.addButton(withTitle: String(localized: "Dismiss")) - - let response: NSApplication.ModalResponse - if let window = AlertHelper.resolveWindow(nil) { - response = await withCheckedContinuation { continuation in - alert.beginSheetModal(for: window) { resp in - continuation.resume(returning: resp) - } - } - } else { - response = alert.runModal() - } - - if response == .alertFirstButtonReturn { - WindowOpener.shared.openSettings(tab: .plugins) - } - } - } - // MARK: - Window Notifications @objc func windowWillClose(_ notification: Notification) { @@ -289,3 +250,35 @@ class AppDelegate: NSObject, NSApplicationDelegate { NSWorkspace.shared.notificationCenter.removeObserver(self) } } + +extension AppDelegate: UNUserNotificationCenterDelegate { + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + guard notification.request.identifier.hasPrefix(PluginNotificationService.identifierPrefix) else { + completionHandler([]) + return + } + completionHandler([.banner]) + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + defer { completionHandler() } + guard response.notification.request.identifier.hasPrefix(PluginNotificationService.identifierPrefix) else { + return + } + let action = response.actionIdentifier + guard action == PluginNotificationService.openPluginSettingsActionId + || action == UNNotificationDefaultActionIdentifier + else { return } + Task { @MainActor in + WindowOpener.shared.openSettings(tab: .plugins) + } + } +} diff --git a/TablePro/Core/Plugins/PluginCodeSignatureVerifier.swift b/TablePro/Core/Plugins/PluginCodeSignatureVerifier.swift new file mode 100644 index 000000000..d9efb19f9 --- /dev/null +++ b/TablePro/Core/Plugins/PluginCodeSignatureVerifier.swift @@ -0,0 +1,89 @@ +// +// PluginCodeSignatureVerifier.swift +// TablePro +// + +import Foundation +import os +import Security + +enum PluginCodeSignatureVerifier { + private static let logger = Logger(subsystem: "com.TablePro", category: "PluginCodeSignature") + private static let fallbackSigningTeamId = "D7HJ5TFYCU" + + static let resolvedSigningTeamId: String = { + guard let teamId = teamIdFromBundleSignature() else { + logger.warning("Could not derive team ID from app signature; using fallback '\(fallbackSigningTeamId)'") + return fallbackSigningTeamId + } + return teamId + }() + + static func verify(bundle: Bundle) throws { + var staticCode: SecStaticCode? + let createStatus = SecStaticCodeCreateWithPath( + bundle.bundleURL as CFURL, + SecCSFlags(), + &staticCode + ) + + guard createStatus == errSecSuccess, let code = staticCode else { + throw PluginError.signatureInvalid(detail: describeOSStatus(createStatus)) + } + + let requirement = createSigningRequirement() + + let checkStatus = SecStaticCodeCheckValidity( + code, + SecCSFlags(rawValue: kSecCSCheckAllArchitectures), + requirement + ) + + guard checkStatus == errSecSuccess else { + throw PluginError.signatureInvalid(detail: describeOSStatus(checkStatus)) + } + } + + private static func createSigningRequirement() -> SecRequirement? { + var requirement: SecRequirement? + let teamId = resolvedSigningTeamId + let requirementString = "anchor apple generic and certificate leaf[subject.OU] = \"\(teamId)\"" as CFString + SecRequirementCreateWithString(requirementString, SecCSFlags(), &requirement) + return requirement + } + + private static func teamIdFromBundleSignature() -> String? { + var staticCode: SecStaticCode? + let createStatus = SecStaticCodeCreateWithPath( + Bundle.main.bundleURL as CFURL, + SecCSFlags(), + &staticCode + ) + guard createStatus == errSecSuccess, let code = staticCode else { return nil } + + var info: CFDictionary? + let infoStatus = SecCodeCopySigningInformation( + code, + SecCSFlags(rawValue: kSecCSSigningInformation), + &info + ) + guard infoStatus == errSecSuccess, + let infoDict = info as? [String: Any], + let teamId = infoDict[kSecCodeInfoTeamIdentifier as String] as? String, + !teamId.isEmpty + else { return nil } + return teamId + } + + private static func describeOSStatus(_ status: OSStatus) -> String { + switch status { + case -67_062: "bundle is not signed" + case -67_061: "code signature is invalid" + case -67_030: "code signature has been modified or corrupted" + case -67_013: "signing certificate has expired" + case -67_058: "code signature is missing required fields" + case -67_028: "resource envelope has been modified" + default: "verification failed (OSStatus \(status))" + } + } +} diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index e031ed9f7..990603e78 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -281,13 +281,13 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { dataSize: pluginMeta.dataSize, indexSize: pluginMeta.indexSize, totalSize: pluginMeta.totalSize, - avgRowLength: nil, + avgRowLength: pluginMeta.avgRowLength, rowCount: pluginMeta.rowCount, comment: pluginMeta.comment, engine: pluginMeta.engine, - collation: nil, - createTime: nil, - updateTime: nil + collation: pluginMeta.collation, + createTime: pluginMeta.createTime, + updateTime: pluginMeta.updateTime ) } diff --git a/TablePro/Core/Plugins/PluginInstaller.swift b/TablePro/Core/Plugins/PluginInstaller.swift new file mode 100644 index 000000000..981f0054e --- /dev/null +++ b/TablePro/Core/Plugins/PluginInstaller.swift @@ -0,0 +1,339 @@ +// +// PluginInstaller.swift +// TablePro +// + +import CryptoKit +import Darwin +import Foundation +import os + +actor PluginInstaller { + static let shared = PluginInstaller() + + static let logger = Logger(subsystem: "com.TablePro", category: "PluginInstaller") + + private var activeTasks: [String: Task] = [:] + private var stagedUpdates: [String: URL] = [:] + + private init() {} + + func install( + _ registryPlugin: RegistryPlugin, + binary: RegistryBinary, + into userPluginsDir: URL, + progressHandler: @escaping @Sendable (StagedInstallState) async -> Void + ) async throws -> URL { + try await runCoalesced(pluginId: registryPlugin.id) { + try await self.performDownloadAndCommit( + registryPlugin, + binary: binary, + into: userPluginsDir, + progressHandler: progressHandler + ) + } + } + + func update( + _ registryPlugin: RegistryPlugin, + binary: RegistryBinary, + into userPluginsDir: URL, + hasLiveConnections: Bool, + progressHandler: @escaping @Sendable (StagedInstallState) async -> Void + ) async throws -> PluginUpdateResult { + if hasLiveConnections { + let stagedURL = try await runCoalesced(pluginId: registryPlugin.id) { + try await self.performDownloadAndStage( + registryPlugin, + binary: binary, + into: userPluginsDir, + progressHandler: progressHandler + ) + } + stagedUpdates[registryPlugin.id] = stagedURL + await progressHandler(.staged(at: stagedURL)) + return .staged(at: stagedURL) + } + + let finalURL = try await runCoalesced(pluginId: registryPlugin.id) { + try await self.performDownloadAndCommit( + registryPlugin, + binary: binary, + into: userPluginsDir, + progressHandler: progressHandler + ) + } + return .installed(pluginURL: finalURL) + } + + func commitStagedUpdate(pluginId: String, into userPluginsDir: URL) async throws -> URL { + guard let stagedURL = stagedUpdates[pluginId] else { + throw PluginError.notFound + } + let bundleName = stagedURL.deletingPathExtension().lastPathComponent + let destURL = userPluginsDir.appendingPathComponent("\(bundleName).tableplugin", isDirectory: true) + let finalURL = try Self.atomicReplace(stagedBundleURL: stagedURL, destURL: destURL) + stagedUpdates.removeValue(forKey: pluginId) + try? FileManager.default.removeItem(at: stagedURL.deletingLastPathComponent()) + return finalURL + } + + func discardStagedUpdate(pluginId: String) { + guard let stagedURL = stagedUpdates.removeValue(forKey: pluginId) else { return } + try? FileManager.default.removeItem(at: stagedURL.deletingLastPathComponent()) + } + + func hasStagedUpdate(pluginId: String) -> Bool { + stagedUpdates[pluginId] != nil + } + + func stagedURL(for pluginId: String) -> URL? { + stagedUpdates[pluginId] + } + + func cancelInstall(pluginId: String) { + activeTasks[pluginId]?.cancel() + } + + // MARK: - Coalescing + + private func runCoalesced( + pluginId: String, + body: @Sendable @escaping () async throws -> URL + ) async throws -> URL { + if let existing = activeTasks[pluginId] { + return try await existing.value + } + let task = Task { + try await body() + } + activeTasks[pluginId] = task + defer { activeTasks.removeValue(forKey: pluginId) } + return try await task.value + } + + // MARK: - Download + commit (no live connections) + + private func performDownloadAndCommit( + _ registryPlugin: RegistryPlugin, + binary: RegistryBinary, + into userPluginsDir: URL, + progressHandler: @escaping @Sendable (StagedInstallState) async -> Void + ) async throws -> URL { + let extracted = try await downloadExtractVerify( + registryPlugin, + binary: binary, + into: userPluginsDir, + progressHandler: progressHandler + ) + + defer { + try? FileManager.default.removeItem(at: extracted.workingDir) + } + + let destURL = userPluginsDir.appendingPathComponent(extracted.bundleURL.lastPathComponent, isDirectory: true) + let finalURL = try Self.atomicReplace(stagedBundleURL: extracted.bundleURL, destURL: destURL) + await progressHandler(.installed(pluginURL: finalURL)) + return finalURL + } + + // MARK: - Download + stage (live connections) + + private func performDownloadAndStage( + _ registryPlugin: RegistryPlugin, + binary: RegistryBinary, + into userPluginsDir: URL, + progressHandler: @escaping @Sendable (StagedInstallState) async -> Void + ) async throws -> URL { + let extracted = try await downloadExtractVerify( + registryPlugin, + binary: binary, + into: userPluginsDir, + progressHandler: progressHandler + ) + return extracted.bundleURL + } + + // MARK: - Download / extract / verify / quarantine-strip + + private struct ExtractedBundle { + let workingDir: URL + let bundleURL: URL + } + + private func downloadExtractVerify( + _ registryPlugin: RegistryPlugin, + binary: RegistryBinary, + into userPluginsDir: URL, + progressHandler: @escaping @Sendable (StagedInstallState) async -> Void + ) async throws -> ExtractedBundle { + let stagingRoot = Self.stagingRoot(for: userPluginsDir) + try FileManager.default.createDirectory(at: stagingRoot, withIntermediateDirectories: true) + let workingDir = stagingRoot.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: workingDir, withIntermediateDirectories: true) + + let context = await MainActor.run { + ( + kit: PluginManager.currentPluginKitVersion, + inspector: PluginManager.currentInspectorKitVersion, + session: RegistryClient.shared.session + ) + } + + guard let downloadURL = URL(string: binary.downloadURL) else { + throw PluginError.downloadFailed("Invalid download URL") + } + + await progressHandler(.downloading(fraction: 0)) + + let (tempDownloadURL, response) = try await context.session.download(from: downloadURL) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + throw PluginError.downloadFailed("HTTP \(code)") + } + + await progressHandler(.downloading(fraction: 0.5)) + + let payload = try Data(contentsOf: tempDownloadURL) + let digest = SHA256.hash(data: payload) + let hex = digest.map { String(format: "%02x", $0) }.joined() + guard hex == binary.sha256.lowercased() else { + throw PluginError.checksumMismatch + } + + await progressHandler(.downloading(fraction: 1.0)) + + let zipURL = workingDir.appendingPathComponent("\(registryPlugin.id).zip") + try FileManager.default.moveItem(at: tempDownloadURL, to: zipURL) + + try Self.extractZip(at: zipURL, into: workingDir) + + let bundleURL = try Self.findBundle(in: workingDir) + guard let stagedBundle = Bundle(url: bundleURL) else { + throw PluginError.invalidBundle("Cannot create bundle from \(bundleURL.lastPathComponent)") + } + + try PluginCodeSignatureVerifier.verify(bundle: stagedBundle) + + try Self.validateStagedABI( + bundleURL: bundleURL, + currentKit: context.kit, + currentInspector: context.inspector + ) + Self.stripQuarantine(at: bundleURL) + + return ExtractedBundle(workingDir: workingDir, bundleURL: bundleURL) + } + + // MARK: - Helpers (nonisolated) + + nonisolated static func stagingRoot(for userPluginsDir: URL) -> URL { + userPluginsDir.deletingLastPathComponent() + .appendingPathComponent("PluginStaging", isDirectory: true) + } + + nonisolated static func extractZip(at zipURL: URL, into destDir: URL) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") + process.arguments = ["-xk", zipURL.path, destDir.path] + try process.run() + process.waitUntilExit() + if process.terminationStatus != 0 { + throw PluginError.installFailed("ditto exit code \(process.terminationStatus)") + } + } + + nonisolated static func findBundle(in directory: URL) throws -> URL { + let contents = try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ).filter { $0.pathExtension == "tableplugin" } + + guard !contents.isEmpty else { + throw PluginError.installFailed("No .tableplugin bundle found in archive") + } + guard contents.count == 1 else { + throw PluginError.installFailed("Archive contains \(contents.count) plugins; only single-plugin archives are supported") + } + return contents[0] + } + + nonisolated static func stripQuarantine(at url: URL) { + let path = url.path + let result = path.withCString { removexattr($0, "com.apple.quarantine", 0) } + guard result != 0 else { return } + let code = errno + if code != ENOATTR { + logger.warning("Failed to remove quarantine xattr at \(url.lastPathComponent): errno=\(code)") + } + } + + nonisolated static func validateStagedABI( + bundleURL: URL, + currentKit: Int, + currentInspector: Int + ) throws { + guard let bundle = Bundle(url: bundleURL), + let info = bundle.infoDictionary + else { + throw PluginError.invalidBundle("Cannot read Info.plist") + } + let declaredKit = info["TableProPluginKitVersion"] as? Int + let declaredInspector = info["TableProInspectorKitVersion"] as? Int + if declaredKit == nil && declaredInspector == nil { + throw PluginError.pluginOutdated(pluginVersion: 0, requiredVersion: currentKit) + } + if let version = declaredKit { + if version > currentKit { + throw PluginError.incompatibleVersion(required: version, current: currentKit) + } + if version < currentKit { + throw PluginError.pluginOutdated(pluginVersion: version, requiredVersion: currentKit) + } + } + if let version = declaredInspector { + if version > currentInspector { + throw PluginError.incompatibleVersion(required: version, current: currentInspector) + } + if version < currentInspector { + throw PluginError.pluginOutdated(pluginVersion: version, requiredVersion: currentInspector) + } + } + } + + nonisolated static func atomicReplace(stagedBundleURL: URL, destURL: URL) throws -> URL { + var resultURL: NSURL? + let backupName = "\(destURL.lastPathComponent).bak" + let destDir = destURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: destDir, withIntermediateDirectories: true) + + if FileManager.default.fileExists(atPath: destURL.path) { + try FileManager.default.replaceItem( + at: destURL, + withItemAt: stagedBundleURL, + backupItemName: backupName, + options: [], + resultingItemURL: &resultURL + ) + let backupURL = destDir.appendingPathComponent(backupName) + try? FileManager.default.removeItem(at: backupURL) + } else { + try FileManager.default.moveItem(at: stagedBundleURL, to: destURL) + } + + return (resultURL as URL?) ?? destURL + } +} + +enum StagedInstallState: Sendable { + case downloading(fraction: Double) + case staged(at: URL) + case installed(pluginURL: URL) + case failed(any Error) +} + +enum PluginUpdateResult: Sendable { + case installed(pluginURL: URL) + case staged(at: URL) +} diff --git a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift index 8f2a702ad..e039a49a2 100644 --- a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift +++ b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift @@ -3,67 +3,133 @@ // TablePro // +import Combine import Foundation import os -extension PluginManager { - func autoUpdateRejectedPlugins() async { - let outdated = rejectedPlugins.filter(\.isOutdated) - guard !outdated.isEmpty else { return } - - Self.logger.info("Attempting auto-update for \(outdated.count) outdated plugin(s)") +private enum ReconciliationConfig { + static let maxAttempts = 3 + static let firstRetryDelay: Duration = .seconds(30) + static let secondRetryDelay: Duration = .seconds(300) +} - let registryClient = RegistryClient.shared - await registryClient.fetchManifest() +extension PluginManager { + func scheduleReconciliation() { + reconciliationTask?.cancel() + reconciliationTask = Task { [weak self] in + await self?.runReconciliationLoop() + } + } - guard let manifest = registryClient.manifest else { - Self.logger.warning("Auto-update skipped: registry manifest unavailable") + func runReconciliationLoop() async { + let outdated = rejectedPlugins.filter(\.isOutdated) + guard !outdated.isEmpty else { + AppEvents.shared.pluginsRejected.send(rejectedPlugins) + refreshRegistryUpdateSet() return } - var stillFailed: [RejectedPlugin] = [] + await RegistryClient.shared.fetchManifest() + refreshRegistryUpdateSet() + guard let manifest = RegistryClient.shared.manifest else { + Self.logger.warning("Reconciliation skipped: registry manifest unavailable") + AppEvents.shared.pluginsRejected.send(rejectedPlugins) + return + } - for plugin in outdated { - let lookupId = plugin.registryId ?? plugin.bundleId + for rejected in outdated { + guard !Task.isCancelled else { return } - guard let lookupId, + guard let lookupId = resolveRegistryId(for: rejected, manifest: manifest), let registryPlugin = manifest.plugins.first(where: { $0.id == lookupId }) else { - Self.logger.warning("Auto-update skipped for '\(plugin.name)': no matching registry plugin") - stillFailed.append(plugin) + Self.logger.warning("Reconciliation: no registry entry for '\(rejected.name)'") continue } + let attempts = reconciliationAttempts[lookupId, default: 0] + guard attempts < ReconciliationConfig.maxAttempts else { + Self.logger.warning("Reconciliation: max attempts reached for '\(rejected.name)'") + continue + } + + reconciliationAttempts[lookupId] = attempts + 1 + do { - _ = try await updateFromRegistry(registryPlugin, existingPluginLoaded: false) { _ in } - Self.logger.info("Auto-updated plugin '\(plugin.name)' to v\(registryPlugin.version)") + let outcome = try await updateFromRegistry( + registryPlugin, + existingPluginLoaded: false, + progress: { _ in } + ) + switch outcome { + case .installed: + removeFromRejected(url: rejected.url) + reconciliationAttempts.removeValue(forKey: lookupId) + refreshRegistryUpdateSet() + Self.logger.info("Reconciliation: auto-updated '\(rejected.name)'") + case .staged: + Self.logger.info("Reconciliation: staged update for '\(rejected.name)' (live connections)") + } } catch { - Self.logger.error("Auto-update failed for '\(plugin.name)': \(error.localizedDescription)") - stillFailed.append(RejectedPlugin( - url: plugin.url, - bundleId: plugin.bundleId, - registryId: plugin.registryId, - name: plugin.name, - reason: error.localizedDescription, - isOutdated: plugin.isOutdated - )) + Self.logger.error("Reconciliation: update failed for '\(rejected.name)': \(error.localizedDescription)") } } - let updatedCount = outdated.count - stillFailed.count - if updatedCount > 0 { - Self.logger.info("Auto-updated \(updatedCount) plugin(s) from registry") + AppEvents.shared.pluginsRejected.send(rejectedPlugins) + scheduleReconciliationRetryIfNeeded(manifest: manifest) + } + + private func scheduleReconciliationRetryIfNeeded(manifest: RegistryManifest) { + let retryable = rejectedPlugins.filter(\.isOutdated).contains { rejected in + guard let id = resolveRegistryId(for: rejected, manifest: manifest) else { return false } + return reconciliationAttempts[id, default: 0] < ReconciliationConfig.maxAttempts + } + guard retryable else { return } + + let round = reconciliationAttempts.values.max() ?? 1 + let delay = round <= 1 ? ReconciliationConfig.firstRetryDelay : ReconciliationConfig.secondRetryDelay + reconciliationTask = Task { [weak self] in + try? await Task.sleep(for: delay) + guard !Task.isCancelled else { return } + await self?.runReconciliationLoop() } + } + + func resolveRegistryId(for rejected: RejectedPlugin, manifest: RegistryManifest) -> String? { + if let id = rejected.registryId { return id } + if let bundleId = rejected.bundleId, + manifest.plugins.contains(where: { $0.id == bundleId }) { + return bundleId + } + return nil + } - let processedURLs = Set(outdated.map(\.url)) - rejectedPlugins = rejectedPlugins.filter { !processedURLs.contains($0.url) } + stillFailed + func removeFromRejected(url: URL) { + rejectedPlugins.removeAll { $0.url == url } } func registryUpdate(for pluginId: String) -> RegistryPlugin? { guard let manifest = RegistryClient.shared.manifest else { return nil } guard let installed = plugins.first(where: { $0.id == pluginId }) else { return nil } + guard installed.source == .userInstalled else { return nil } guard let registryPlugin = manifest.plugins.first(where: { $0.id == pluginId }) else { return nil } guard registryPlugin.category != .theme else { return nil } return registryPlugin.version.compare(installed.version, options: .numeric) == .orderedDescending ? registryPlugin : nil } + + func refreshRegistryUpdateSet() { + var available: Set = [] + for plugin in plugins where registryUpdate(for: plugin.id) != nil { + available.insert(plugin.id) + } + if available != pluginsWithRegistryUpdate { + pluginsWithRegistryUpdate = available + } + } + + func registryPlugin(for rejected: RejectedPlugin) -> RegistryPlugin? { + guard let manifest = RegistryClient.shared.manifest else { return nil } + guard let id = resolveRegistryId(for: rejected, manifest: manifest) else { return nil } + return manifest.plugins.first(where: { $0.id == id }) + } } diff --git a/TablePro/Core/Plugins/PluginManager+Install.swift b/TablePro/Core/Plugins/PluginManager+Install.swift new file mode 100644 index 000000000..51a87c0d1 --- /dev/null +++ b/TablePro/Core/Plugins/PluginManager+Install.swift @@ -0,0 +1,242 @@ +// +// PluginManager+Install.swift +// TablePro +// + +import Foundation +import os + +extension PluginManager { + func installFromRegistry( + _ registryPlugin: RegistryPlugin, + progress: @escaping @MainActor @Sendable (Double) -> Void + ) async throws -> PluginEntry { + let binary = try validateRegistryCompatibility(registryPlugin) + if plugins.contains(where: { $0.id == registryPlugin.id }) { + throw PluginError.pluginConflict(existingName: registryPlugin.name) + } + guard !installsInFlight.contains(registryPlugin.id) else { + throw PluginError.installFailed( + String(localized: "Another install is already in progress for this plugin") + ) + } + installsInFlight.insert(registryPlugin.id) + defer { installsInFlight.remove(registryPlugin.id) } + + let userPluginsDir = self.userPluginsDir + let stateHandler: @Sendable (StagedInstallState) async -> Void = { state in + if case .downloading(let fraction) = state { + await MainActor.run { progress(fraction) } + } + } + + let finalURL = try await PluginInstaller.shared.install( + registryPlugin, + binary: binary, + into: userPluginsDir, + progressHandler: stateHandler + ) + + saveRegistryMetadata(pluginId: registryPlugin.id, pluginURL: finalURL) + let entry = try await loadPluginAsync(at: finalURL, source: .userInstalled) + refreshRegistryUpdateSet() + return entry + } + + func updateFromRegistry( + _ registryPlugin: RegistryPlugin, + existingPluginLoaded: Bool = true, + progress: @escaping @MainActor @Sendable (Double) -> Void + ) async throws -> PluginUpdateOutcome { + let binary = try validateRegistryCompatibility(registryPlugin) + + if let existing = plugins.first(where: { $0.id == registryPlugin.id }), + existing.source == .builtIn { + throw PluginError.pluginConflict(existingName: existing.name) + } + + guard !installsInFlight.contains(registryPlugin.id) else { + throw PluginError.installFailed( + String(localized: "Another install is already in progress for this plugin") + ) + } + installsInFlight.insert(registryPlugin.id) + defer { installsInFlight.remove(registryPlugin.id) } + + let hasLive = pluginHasLiveConnections(registryPlugin) + let userPluginsDir = self.userPluginsDir + let stateHandler: @Sendable (StagedInstallState) async -> Void = { state in + if case .downloading(let fraction) = state { + await MainActor.run { progress(fraction) } + } + } + + let result = try await PluginInstaller.shared.update( + registryPlugin, + binary: binary, + into: userPluginsDir, + hasLiveConnections: hasLive, + progressHandler: stateHandler + ) + + switch result { + case .installed(let pluginURL): + saveRegistryMetadata(pluginId: registryPlugin.id, pluginURL: pluginURL) + let entry = try await loadPluginAsync( + at: pluginURL, + source: .userInstalled, + replacingBundleId: registryPlugin.id + ) + stagedUpdates.removeValue(forKey: registryPlugin.id) + PluginInstallTracker.shared.completeInstall(pluginId: registryPlugin.id) + refreshRegistryUpdateSet() + return .installed(entry) + case .staged(let stagedURL): + stagedUpdates[registryPlugin.id] = StagedPluginUpdate( + registryPlugin: registryPlugin, + stagedURL: stagedURL + ) + PluginInstallTracker.shared.markStaged( + pluginId: registryPlugin.id, + newVersion: registryPlugin.version + ) + return .staged(pluginId: registryPlugin.id) + } + } + + func installPlugin(from url: URL) async throws -> PluginEntry { + if url.pathExtension == "tableplugin" { + return try await installLooseBundle(from: url) + } + return try await installLocalZip(from: url) + } + + func commitStagedUpdate(pluginId: String) async throws -> PluginEntry { + guard let pending = stagedUpdates[pluginId] else { + throw PluginError.notFound + } + guard !pluginHasLiveConnections(pending.registryPlugin) else { + throw PluginError.installFailed( + String(localized: "Plugin has active connections") + ) + } + guard !installsInFlight.contains(pluginId) else { + throw PluginError.installFailed( + String(localized: "Another install is already in progress for this plugin") + ) + } + installsInFlight.insert(pluginId) + defer { + installsInFlight.remove(pluginId) + stagedUpdates.removeValue(forKey: pluginId) + } + let finalURL = try await PluginInstaller.shared.commitStagedUpdate( + pluginId: pluginId, + into: userPluginsDir + ) + saveRegistryMetadata(pluginId: pluginId, pluginURL: finalURL) + let entry = try await loadPluginAsync( + at: finalURL, + source: .userInstalled, + replacingBundleId: pluginId + ) + PluginInstallTracker.shared.completeInstall(pluginId: pluginId) + refreshRegistryUpdateSet() + return entry + } + + func discardStagedUpdate(pluginId: String) async { + await PluginInstaller.shared.discardStagedUpdate(pluginId: pluginId) + stagedUpdates.removeValue(forKey: pluginId) + PluginInstallTracker.shared.clearInstall(pluginId: pluginId) + } + + @discardableResult + func validateRegistryCompatibility(_ registryPlugin: RegistryPlugin) throws -> RegistryBinary { + if let minAppVersion = registryPlugin.minAppVersion { + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" + if appVersion.compare(minAppVersion, options: .numeric) == .orderedAscending { + throw PluginError.incompatibleWithCurrentApp(minimumRequired: minAppVersion) + } + } + return try registryPlugin.resolvedBinary( + for: .current, + pluginKitVersion: Self.currentPluginKitVersion + ) + } + + func pluginHasLiveConnections(_ registryPlugin: RegistryPlugin) -> Bool { + let typeIds = Set(registryPlugin.databaseTypeIds ?? [registryPlugin.id]) + return DatabaseManager.shared.activeSessions.values.contains { session in + typeIds.contains(session.connection.type.pluginTypeId) + } + } + + func reattemptStagedUpdates() { + for (pluginId, pending) in stagedUpdates where !pluginHasLiveConnections(pending.registryPlugin) { + guard !installsInFlight.contains(pluginId) else { continue } + Task { [weak self] in + _ = try? await self?.commitStagedUpdate(pluginId: pluginId) + } + } + } + + // MARK: - Local bundle / zip install + + private func installLooseBundle(from url: URL) async throws -> PluginEntry { + guard let sourceBundle = Bundle(url: url) else { + throw PluginError.invalidBundle("Cannot create bundle from \(url.lastPathComponent)") + } + try PluginCodeSignatureVerifier.verify(bundle: sourceBundle) + let bundleId = sourceBundle.bundleIdentifier ?? url.lastPathComponent + + try FileManager.default.createDirectory(at: userPluginsDir, withIntermediateDirectories: true) + let destURL = userPluginsDir.appendingPathComponent(url.lastPathComponent) + let replaceId: String? = plugins.contains(where: { $0.id == bundleId }) ? bundleId : nil + + if url.standardizedFileURL != destURL.standardizedFileURL { + let finalURL = try PluginInstaller.atomicReplace(stagedBundleURL: url, destURL: destURL) + return try await loadPluginAsync(at: finalURL, source: .userInstalled, replacingBundleId: replaceId) + } + return try await loadPluginAsync(at: destURL, source: .userInstalled, replacingBundleId: replaceId) + } + + private func installLocalZip(from url: URL) async throws -> PluginEntry { + let stagingRoot = PluginInstaller.stagingRoot(for: userPluginsDir) + try FileManager.default.createDirectory(at: stagingRoot, withIntermediateDirectories: true) + let workingDir = stagingRoot.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: workingDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: workingDir) } + + try PluginInstaller.extractZip(at: url, into: workingDir) + let bundleURL = try PluginInstaller.findBundle(in: workingDir) + guard let bundle = Bundle(url: bundleURL) else { + throw PluginError.invalidBundle("Cannot create bundle from \(bundleURL.lastPathComponent)") + } + try PluginCodeSignatureVerifier.verify(bundle: bundle) + try PluginInstaller.validateStagedABI( + bundleURL: bundleURL, + currentKit: Self.currentPluginKitVersion, + currentInspector: Self.currentInspectorKitVersion + ) + PluginInstaller.stripQuarantine(at: bundleURL) + + let bundleId = bundle.bundleIdentifier ?? bundleURL.lastPathComponent + let replaceId: String? = plugins.contains(where: { $0.id == bundleId }) ? bundleId : nil + + try FileManager.default.createDirectory(at: userPluginsDir, withIntermediateDirectories: true) + let destURL = userPluginsDir.appendingPathComponent(bundleURL.lastPathComponent) + let finalURL = try PluginInstaller.atomicReplace(stagedBundleURL: bundleURL, destURL: destURL) + return try await loadPluginAsync(at: finalURL, source: .userInstalled, replacingBundleId: replaceId) + } +} + +enum PluginUpdateOutcome: Sendable { + case installed(PluginEntry) + case staged(pluginId: String) +} + +struct StagedPluginUpdate: Sendable { + let registryPlugin: RegistryPlugin + let stagedURL: URL +} diff --git a/TablePro/Core/Plugins/PluginManager+Lifecycle.swift b/TablePro/Core/Plugins/PluginManager+Lifecycle.swift index 123a0a396..b418f46fa 100644 --- a/TablePro/Core/Plugins/PluginManager+Lifecycle.swift +++ b/TablePro/Core/Plugins/PluginManager+Lifecycle.swift @@ -9,8 +9,6 @@ import Security import SwiftUI import TableProPluginKit -// MARK: - Enable / Disable - extension PluginManager { func setEnabled(_ enabled: Bool, pluginId: String) { guard let index = plugins.firstIndex(where: { $0.id == pluginId }) else { return } @@ -38,123 +36,7 @@ extension PluginManager { Self.logger.info("Plugin '\(pluginId)' \(enabled ? "enabled" : "disabled")") } - // MARK: - Install / Uninstall - - func installPlugin(from url: URL) async throws -> PluginEntry { - guard !isInstalling else { - throw PluginError.installFailed("Another plugin installation is already in progress") - } - isInstalling = true - defer { isInstalling = false } - return try await performInstallAssumingLock(from: url) - } - - func performInstallAssumingLock(from url: URL) async throws -> PluginEntry { - if url.pathExtension == "tableplugin" { - return try await installBundle(from: url) - } else { - return try await installFromZip(from: url) - } - } - - private func installBundle(from url: URL) async throws -> PluginEntry { - guard let sourceBundle = Bundle(url: url) else { - throw PluginError.invalidBundle("Cannot create bundle from \(url.lastPathComponent)") - } - - try verifyCodeSignature(bundle: sourceBundle) - - let newBundleId = sourceBundle.bundleIdentifier ?? url.lastPathComponent - replaceExistingPlugin(bundleId: newBundleId) - - let fm = FileManager.default - try fm.createDirectory(at: userPluginsDir, withIntermediateDirectories: true) - let destURL = userPluginsDir.appendingPathComponent(url.lastPathComponent) - - if url.standardizedFileURL != destURL.standardizedFileURL { - if fm.fileExists(atPath: destURL.path) { - try fm.removeItem(at: destURL) - } - try fm.copyItem(at: url, to: destURL) - } - - let entry = try await loadPluginAsync(at: destURL, source: .userInstalled) - - Self.logger.info("Installed plugin '\(entry.name)' v\(entry.version)") - return entry - } - - private func installFromZip(from url: URL) async throws -> PluginEntry { - let fm = FileManager.default - let tempDir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - - defer { - try? fm.removeItem(at: tempDir) - } - - try fm.createDirectory(at: tempDir, withIntermediateDirectories: true) - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") - process.arguments = ["-xk", url.path, tempDir.path] - - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - process.terminationHandler = { proc in - if proc.terminationStatus == 0 { - continuation.resume() - } else { - continuation.resume(throwing: PluginError.installFailed( - "Failed to extract archive (ditto exit code \(proc.terminationStatus))" - )) - } - } - do { - try process.run() - } catch { - continuation.resume(throwing: error) - } - } - - let extractedBundles = try fm.contentsOfDirectory( - at: tempDir, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles] - ).filter { $0.pathExtension == "tableplugin" } - - guard !extractedBundles.isEmpty else { - throw PluginError.installFailed("No .tableplugin bundle found in archive") - } - - guard extractedBundles.count == 1 else { - throw PluginError.installFailed( - "Archive contains \(extractedBundles.count) plugins; only single-plugin archives are supported" - ) - } - - let extracted = extractedBundles[0] - guard let extractedBundle = Bundle(url: extracted) else { - throw PluginError.invalidBundle("Cannot create bundle from extracted plugin '\(extracted.lastPathComponent)'") - } - - try verifyCodeSignature(bundle: extractedBundle) - - let newBundleId = extractedBundle.bundleIdentifier ?? extracted.lastPathComponent - replaceExistingPlugin(bundleId: newBundleId) - - try fm.createDirectory(at: userPluginsDir, withIntermediateDirectories: true) - let destURL = userPluginsDir.appendingPathComponent(extracted.lastPathComponent) - - if fm.fileExists(atPath: destURL.path) { - try fm.removeItem(at: destURL) - } - try fm.copyItem(at: extracted, to: destURL) - - let entry = try await loadPluginAsync(at: destURL, source: .userInstalled) - Self.logger.info("Installed plugin '\(entry.name)' v\(entry.version)") - return entry - } - - func uninstallPlugin(id: String) throws { + func uninstallPlugin(id: String) async throws { guard let index = plugins.firstIndex(where: { $0.id == id }) else { throw PluginError.notFound } @@ -182,7 +64,12 @@ extension PluginManager { disabled.remove(id) disabledPluginIds = disabled + if stagedUpdates[id] != nil { + await discardStagedUpdate(pluginId: id) + } + queryBuildingDriverCache.removeAll() + refreshRegistryUpdateSet() Self.logger.info("Uninstalled plugin '\(id)'") needsRestart = true diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index ca29847a3..4b2cdc740 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -470,7 +470,6 @@ extension PluginManager { }) { if !existingEntry.isEnabled { setEnabled(true, pluginId: existingEntry.id) - await loadPendingPluginsAsync() } if driverPlugins[pluginTypeId] != nil { Self.logger.info("Re-enabled existing plugin '\(existingEntry.name)' for '\(databaseType.rawValue)'") @@ -479,7 +478,7 @@ extension PluginManager { Self.logger.warning("Plugin '\(existingEntry.id)' exists but driver not registered, reinstalling") if existingEntry.source == .userInstalled { do { - try uninstallPlugin(id: existingEntry.id) + try await uninstallPlugin(id: existingEntry.id) } catch { Self.logger.warning("Failed to uninstall plugin '\(existingEntry.id)' before reinstall: \(error.localizedDescription)") } diff --git a/TablePro/Core/Plugins/PluginManager+Validation.swift b/TablePro/Core/Plugins/PluginManager+Validation.swift index 053083a97..a69490c37 100644 --- a/TablePro/Core/Plugins/PluginManager+Validation.swift +++ b/TablePro/Core/Plugins/PluginManager+Validation.swift @@ -5,12 +5,8 @@ import Foundation import os -import Security -import SwiftUI import TableProPluginKit -// MARK: - Dependency Validation - extension PluginManager { func validateDependencies() { let loadedIds = Set(plugins.map(\.id)) @@ -28,87 +24,7 @@ extension PluginManager { } } - // MARK: - Code Signature Verification - - private static let fallbackSigningTeamId = "D7HJ5TFYCU" - - private static let resolvedSigningTeamId: String = { - guard let teamId = teamIdFromBundleSignature() else { - logger.warning("Could not derive team ID from app signature; using fallback '\(fallbackSigningTeamId)'") - return fallbackSigningTeamId - } - return teamId - }() - - private static func teamIdFromBundleSignature() -> String? { - var staticCode: SecStaticCode? - let createStatus = SecStaticCodeCreateWithPath( - Bundle.main.bundleURL as CFURL, - SecCSFlags(), - &staticCode - ) - guard createStatus == errSecSuccess, let code = staticCode else { return nil } - - var info: CFDictionary? - let infoStatus = SecCodeCopySigningInformation( - code, - SecCSFlags(rawValue: kSecCSSigningInformation), - &info - ) - guard infoStatus == errSecSuccess, - let infoDict = info as? [String: Any], - let teamId = infoDict[kSecCodeInfoTeamIdentifier as String] as? String, - !teamId.isEmpty - else { return nil } - return teamId - } - - private func createSigningRequirement() -> SecRequirement? { - var requirement: SecRequirement? - let teamId = Self.resolvedSigningTeamId - let requirementString = "anchor apple generic and certificate leaf[subject.OU] = \"\(teamId)\"" as CFString - SecRequirementCreateWithString(requirementString, SecCSFlags(), &requirement) - return requirement - } - func verifyCodeSignature(bundle: Bundle) throws { - var staticCode: SecStaticCode? - let createStatus = SecStaticCodeCreateWithPath( - bundle.bundleURL as CFURL, - SecCSFlags(), - &staticCode - ) - - guard createStatus == errSecSuccess, let code = staticCode else { - throw PluginError.signatureInvalid( - detail: Self.describeOSStatus(createStatus) - ) - } - - let requirement = createSigningRequirement() - - let checkStatus = SecStaticCodeCheckValidity( - code, - SecCSFlags(rawValue: kSecCSCheckAllArchitectures), - requirement - ) - - guard checkStatus == errSecSuccess else { - throw PluginError.signatureInvalid( - detail: Self.describeOSStatus(checkStatus) - ) - } - } - - private static func describeOSStatus(_ status: OSStatus) -> String { - switch status { - case -67_062: return "bundle is not signed" - case -67_061: return "code signature is invalid" - case -67_030: return "code signature has been modified or corrupted" - case -67_013: return "signing certificate has expired" - case -67_058: return "code signature is missing required fields" - case -67_028: return "resource envelope has been modified" - default: return "verification failed (OSStatus \(status))" - } + try PluginCodeSignatureVerifier.verify(bundle: bundle) } } diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 6b29e120b..3e7da2436 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -24,7 +24,18 @@ final class PluginManager { internal(set) var plugins: [PluginEntry] = [] - internal(set) var isInstalling = false + internal(set) var stagedUpdates: [String: StagedPluginUpdate] = [:] + + internal(set) var pluginsWithRegistryUpdate: Set = [] + + var isInstalling: Bool { + PluginInstallTracker.shared.activeInstalls.values.contains { progress in + switch progress.phase { + case .downloading, .installing: true + case .stagedPendingActivation, .completed, .failed: false + } + } + } internal(set) var hasFinishedInitialLoad = false { didSet { @@ -99,6 +110,11 @@ final class PluginManager { @ObservationIgnored internal var lazyInspectorUTIs: [String: URL] = [:] @ObservationIgnored private var activatedBundleIds: Set = [] + @ObservationIgnored internal var reconciliationTask: Task? + @ObservationIgnored internal var reconciliationAttempts: [String: Int] = [:] + @ObservationIgnored private var connectionStatusSubscription: AnyCancellable? + @ObservationIgnored internal var installsInFlight: Set = [] + var queryBuildingDriverCache: [String: (any PluginDatabaseDriver)?] = [:] init( @@ -126,7 +142,7 @@ final class PluginManager { // MARK: - Registry Metadata - private struct RegistryMetadata: Codable { + struct RegistryMetadata: Codable { let pluginId: String } @@ -135,7 +151,7 @@ final class PluginManager { .appendingPathComponent(pluginURL.lastPathComponent + ".metadata.json") } - nonisolated private static func readRegistryMetadata(for pluginURL: URL) -> RegistryMetadata? { + nonisolated static func readRegistryMetadata(for pluginURL: URL) -> RegistryMetadata? { let url = metadataURL(for: pluginURL) guard let data = try? Data(contentsOf: url) else { return nil } return try? JSONDecoder().decode(RegistryMetadata.self, from: data) @@ -183,7 +199,9 @@ final class PluginManager { func loadPlugins() { migrateDisabledPluginsKey() + cleanStaleStagingArtifacts() discoverAllPlugins() + var lazyPending: [(url: URL, source: PluginSource, manifest: PluginManifest)] = [] var eagerPending: [(url: URL, source: PluginSource)] = [] for entry in pendingPluginURLs { @@ -204,30 +222,65 @@ final class PluginManager { registerLazyManifest(at: entry.url, source: entry.source, manifest: entry.manifest) } + let lazyCount = lazyPending.count Task { - if !self.rejectedPlugins.isEmpty { - await self.autoUpdateRejectedPlugins() - } let validated = await Self.validateAndLoadBundles(eagerPending) - self.needsRestart = false self.registerValidatedBundles(validated) self.validateDependencies() self.hasFinishedInitialLoad = true - let lazyCount = lazyPending.count let eagerCount = validated.count Self.logger.info("Loaded \(self.plugins.count) plugin(s): \(lazyCount) lazy + \(eagerCount) eager (\(self.driverPlugins.count) driver(s) active, \(self.exportPlugins.count) export(s) active, \(self.importPlugins.count) import(s) active)") - if !self.rejectedPlugins.isEmpty { - AppEvents.shared.pluginsRejected.send(self.rejectedPlugins) + + self.refreshRegistryUpdateSet() + self.subscribeToConnectionStatusChanges() + self.scheduleReconciliation() + } + } + + private func cleanStaleStagingArtifacts() { + let stagingRoot = PluginInstaller.stagingRoot(for: userPluginsDir) + let pluginsDir = userPluginsDir + Task.detached(priority: .utility) { + let fm = FileManager.default + if let stagingContents = try? fm.contentsOfDirectory( + at: stagingRoot, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) { + for item in stagingContents { + try? fm.removeItem(at: item) + } + } + if let pluginContents = try? fm.contentsOfDirectory( + at: pluginsDir, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) { + for item in pluginContents where item.pathExtension == "bak" { + try? fm.removeItem(at: item) + } } } } + private func subscribeToConnectionStatusChanges() { + guard connectionStatusSubscription == nil else { return } + connectionStatusSubscription = AppEvents.shared.connectionStatusChanged + .receive(on: RunLoop.main) + .sink { [weak self] change in + guard let self else { return } + if case .disconnected = change.status { + self.reattemptStagedUpdates() + } + } + } + // MARK: - Lazy Plugin Activation private func registerLazyManifest(at url: URL, source: PluginSource, manifest: PluginManifest) { guard let bundle = Bundle(url: url) else { return } do { - try Self.validateBundleVersions(bundle, source: source) + try Self.validateBundleVersions(bundle) } catch { Self.logger.error("Lazy plugin '\(manifest.bundleId)' failed version check: \(error.localizedDescription)") if source == .userInstalled { @@ -294,7 +347,10 @@ final class PluginManager { databaseTypeId: primaryTypeId, additionalTypeIds: additionalTypeIds, pluginIconName: pluginIconName, - defaultPort: defaultPort + defaultPort: defaultPort, + exportFormatId: manifest.providedExportFormatIds.first, + importFormatId: manifest.providedImportFormatIds.first, + inspectorId: manifest.providedInspectorIds.first ) plugins.append(entry) @@ -389,10 +445,7 @@ final class PluginManager { let bundle: Bundle } - nonisolated private static func validateBundleVersions( - _ bundle: Bundle, - source: PluginSource - ) throws { + nonisolated private static func validateBundleVersions(_ bundle: Bundle) throws { let infoPlist = bundle.infoDictionary ?? [:] let declaredPluginKit = infoPlist["TableProPluginKitVersion"] as? Int let declaredInspectorKit = infoPlist["TableProInspectorKitVersion"] as? Int @@ -450,7 +503,7 @@ final class PluginManager { throw PluginError.invalidBundle("Cannot create bundle from \(url.lastPathComponent)") } - try validateBundleVersions(bundle, source: source) + try validateBundleVersions(bundle) guard bundle.load() else { throw PluginError.invalidBundle("Bundle failed to load executable") @@ -482,44 +535,20 @@ final class PluginManager { let bundleId = bundle.bundleIdentifier ?? url.lastPathComponent - let rawDriverType = principalClass as? any DriverPlugin.Type - let rawInspectorType = principalClass as? any DocumentInspectorPlugin.Type - let pluginKitVersion = bundle.infoDictionary?["TableProPluginKitVersion"] as? Int ?? 0 - let inspectorKitVersion = bundle.infoDictionary?["TableProInspectorKitVersion"] as? Int ?? 0 - if rawDriverType != nil, source == .userInstalled, pluginKitVersion != Self.currentPluginKitVersion { - assertionFailure( - "DriverPlugin '\(bundleId)' has TableProPluginKitVersion \(pluginKitVersion) but current is \(Self.currentPluginKitVersion); ABI mismatch would crash on static property access" - ) - Self.logger.error("Plugin '\(bundleId)' DriverPlugin ABI mismatch: plist=\(pluginKitVersion) current=\(Self.currentPluginKitVersion). Rejecting to prevent crash.") - rejectedPlugins.append(RejectedPlugin( - url: url, - bundleId: bundleId, - registryId: Self.readRegistryMetadata(for: url)?.pluginId, - name: principalClass.pluginName, - reason: String(localized: "Incompatible plugin version"), - isOutdated: pluginKitVersion < Self.currentPluginKitVersion - )) - return nil - } - if rawInspectorType != nil, source == .userInstalled, inspectorKitVersion != Self.currentInspectorKitVersion { - assertionFailure( - "DocumentInspectorPlugin '\(bundleId)' has TableProInspectorKitVersion \(inspectorKitVersion) but current is \(Self.currentInspectorKitVersion); ABI mismatch would crash on static property access" - ) - Self.logger.error("Plugin '\(bundleId)' DocumentInspectorPlugin ABI mismatch: plist=\(inspectorKitVersion) current=\(Self.currentInspectorKitVersion). Rejecting to prevent crash.") - rejectedPlugins.append(RejectedPlugin( - url: url, - bundleId: bundleId, - registryId: Self.readRegistryMetadata(for: url)?.pluginId, - name: principalClass.pluginName, - reason: String(localized: "Incompatible plugin version"), - isOutdated: inspectorKitVersion < Self.currentInspectorKitVersion - )) - return nil - } + let driverType = principalClass as? any DriverPlugin.Type + let exportType = principalClass as? any ExportFormatPlugin.Type + let importType = principalClass as? any ImportFormatPlugin.Type + let inspectorType = principalClass as? any DocumentInspectorPlugin.Type let disabled = disabledPluginIds - let driverType = rawDriverType - let version = (bundle.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.0.0" + let info = bundle.infoDictionary ?? [:] + let version: String + if let declared = info["CFBundleShortVersionString"] as? String { + version = declared + } else { + Self.logger.warning("Plugin '\(bundleId)' missing CFBundleShortVersionString; defaulting to 0.0.0") + version = "0.0.0" + } let entry = PluginEntry( id: bundleId, bundle: bundle, @@ -533,7 +562,10 @@ final class PluginManager { databaseTypeId: driverType?.databaseTypeId, additionalTypeIds: driverType?.additionalDatabaseTypeIds ?? [], pluginIconName: driverType?.iconName ?? "puzzlepiece", - defaultPort: driverType?.defaultPort + defaultPort: driverType?.defaultPort, + exportFormatId: exportType?.formatId, + importFormatId: importType?.formatId, + inspectorId: inspectorType?.inspectorId ) plugins.append(entry) @@ -695,34 +727,12 @@ final class PluginManager { Self.logger.info("Loaded \(self.plugins.count) plugin(s): \(self.driverPlugins.count) driver(s), \(self.exportPlugins.count) export format(s), \(self.importPlugins.count) import format(s)") } - func loadPendingPlugins(clearRestartFlag: Bool = false) { - if clearRestartFlag { - needsRestart = false - } - guard !pendingPluginURLs.isEmpty else { return } - let pending = pendingPluginURLs - pendingPluginURLs.removeAll() - - for entry in pending { - do { - try loadPlugin(at: entry.url, source: entry.source) - } catch { - Self.logger.error("Failed to load plugin at \(entry.url.lastPathComponent): \(error.localizedDescription)") - } - } - - queryBuildingDriverCache.removeAll() - hasFinishedInitialLoad = true - validateDependencies() - Self.logger.info("Loaded \(self.plugins.count) plugin(s): \(self.driverPlugins.count) driver(s), \(self.exportPlugins.count) export format(s), \(self.importPlugins.count) import format(s)") - } - private func discoverPlugin(at url: URL, source: PluginSource) throws { guard let bundle = Bundle(url: url) else { throw PluginError.invalidBundle("Cannot create bundle from \(url.lastPathComponent)") } - try Self.validateBundleVersions(bundle, source: source) + try Self.validateBundleVersions(bundle) if source == .userInstalled { try verifyCodeSignature(bundle: bundle) @@ -732,39 +742,17 @@ final class PluginManager { } @discardableResult - func loadPlugin(at url: URL, source: PluginSource) throws -> PluginEntry { - guard let bundle = Bundle(url: url) else { - throw PluginError.invalidBundle("Cannot create bundle from \(url.lastPathComponent)") - } - - try Self.validateBundleVersions(bundle, source: source) - - if source == .userInstalled { - try verifyCodeSignature(bundle: bundle) - } - - guard bundle.load() else { - throw PluginError.invalidBundle("Bundle failed to load executable") - } + func loadPluginAsync( + at url: URL, + source: PluginSource, + replacingBundleId: String? = nil + ) async throws -> PluginEntry { + let loaded = try await Self.validateAndLoadBundleAsync(at: url, source: source) - guard let entry = registerBundle(bundle, url: url, source: source) else { - throw PluginError.invalidBundle("Principal class does not conform to TableProPlugin") + if let replacingBundleId { + replaceExistingPlugin(bundleId: replacingBundleId) } - return entry - } - - @discardableResult - func loadPluginAsync(at url: URL, source: PluginSource) async throws -> PluginEntry { - if source == .userInstalled { - guard let bundle = Bundle(url: url) else { - throw PluginError.invalidBundle("Cannot create bundle from \(url.lastPathComponent)") - } - try verifyCodeSignature(bundle: bundle) - } - - let loaded = try await Self.validateAndLoadBundleAsync(at: url, source: source) - guard let entry = registerBundle(loaded, url: url, source: source) else { throw PluginError.invalidBundle("Principal class does not conform to TableProPlugin") } @@ -776,16 +764,9 @@ final class PluginManager { at url: URL, source: PluginSource ) async throws -> Bundle { - try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - do { - let bundle = try validateAndLoadBundle(at: url, source: source) - continuation.resume(returning: bundle) - } catch { - continuation.resume(throwing: error) - } - } - } + try await Task.detached(priority: .userInitiated) { + try Self.validateAndLoadBundle(at: url, source: source) + }.value } func diagnose(error: Error, for type: DatabaseType) -> PluginDiagnostic? { @@ -824,19 +805,14 @@ final class PluginManager { } } - if let exportClass = entry.bundle.principalClass as? any ExportFormatPlugin.Type { - let formatId = exportClass.formatId - exportPlugins = exportPlugins.filter { key, _ in key != formatId } + if let formatId = entry.exportFormatId { + exportPlugins.removeValue(forKey: formatId) } - - if let importClass = entry.bundle.principalClass as? any ImportFormatPlugin.Type { - let formatId = importClass.formatId - importPlugins = importPlugins.filter { key, _ in key != formatId } + if let formatId = entry.importFormatId { + importPlugins.removeValue(forKey: formatId) } - - if let inspectorClass = entry.bundle.principalClass as? any DocumentInspectorPlugin.Type { - let inspectorId = inspectorClass.inspectorId - inspectorPlugins = inspectorPlugins.filter { key, _ in key != inspectorId } + if let inspectorId = entry.inspectorId { + inspectorPlugins.removeValue(forKey: inspectorId) } } } diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index a3bf977a0..224e7e52a 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -2,11 +2,6 @@ // PluginMetadataRegistry.swift // TablePro // -// Thread-safe, non-actor metadata cache populated at compile time. -// All static plugin metadata is served from here, eliminating metatype -// dispatch on dynamically loaded bundles (which can crash due to -// missing witness table entries). -// import Foundation import TableProPluginKit @@ -734,7 +729,6 @@ final class PluginMetadataRegistry: @unchecked Sendable { } } - // Built-in type aliases: multi-type plugins where an alias maps to a primary plugin type ID reverseTypeIndex["MariaDB"] = "MySQL" reverseTypeIndex["Redshift"] = "PostgreSQL" reverseTypeIndex["CockroachDB"] = "PostgreSQL" diff --git a/TablePro/Core/Plugins/PluginModels.swift b/TablePro/Core/Plugins/PluginModels.swift index 15f58d18a..0bd355a9b 100644 --- a/TablePro/Core/Plugins/PluginModels.swift +++ b/TablePro/Core/Plugins/PluginModels.swift @@ -21,6 +21,10 @@ struct PluginEntry: Identifiable { let additionalTypeIds: [String] let pluginIconName: String let defaultPort: Int? + + let exportFormatId: String? + let importFormatId: String? + let inspectorId: String? } enum PluginSource { diff --git a/TablePro/Core/Plugins/PluginUpdateCoordinator.swift b/TablePro/Core/Plugins/PluginUpdateCoordinator.swift new file mode 100644 index 000000000..ad38f6e55 --- /dev/null +++ b/TablePro/Core/Plugins/PluginUpdateCoordinator.swift @@ -0,0 +1,56 @@ +// +// PluginUpdateCoordinator.swift +// TablePro +// + +import Foundation + +extension PluginManager { + func performRegistryUpdate(_ registryPlugin: RegistryPlugin) async -> PluginActionResult { + let tracker = PluginInstallTracker.shared + tracker.beginInstall(pluginId: registryPlugin.id) + do { + let outcome = try await updateFromRegistry(registryPlugin) { fraction in + tracker.updateProgress(pluginId: registryPlugin.id, fraction: fraction) + if fraction >= 1.0 { + tracker.markInstalling(pluginId: registryPlugin.id) + } + } + switch outcome { + case .installed(let entry): + tracker.completeInstall(pluginId: registryPlugin.id) + return .succeeded(entry: entry) + case .staged: + tracker.markStaged(pluginId: registryPlugin.id, newVersion: registryPlugin.version) + return .staged + } + } catch { + tracker.failInstall(pluginId: registryPlugin.id, error: error.localizedDescription) + return .failed(error: error) + } + } + + func performRegistryInstall(_ registryPlugin: RegistryPlugin) async -> PluginActionResult { + let tracker = PluginInstallTracker.shared + tracker.beginInstall(pluginId: registryPlugin.id) + do { + let entry = try await installFromRegistry(registryPlugin) { fraction in + tracker.updateProgress(pluginId: registryPlugin.id, fraction: fraction) + if fraction >= 1.0 { + tracker.markInstalling(pluginId: registryPlugin.id) + } + } + tracker.completeInstall(pluginId: registryPlugin.id) + return .succeeded(entry: entry) + } catch { + tracker.failInstall(pluginId: registryPlugin.id, error: error.localizedDescription) + return .failed(error: error) + } + } +} + +enum PluginActionResult: Sendable { + case succeeded(entry: PluginEntry) + case staged + case failed(error: any Error) +} diff --git a/TablePro/Core/Plugins/Registry/DownloadCountService.swift b/TablePro/Core/Plugins/Registry/DownloadCountService.swift index 31170a5a4..d355e4ba1 100644 --- a/TablePro/Core/Plugins/Registry/DownloadCountService.swift +++ b/TablePro/Core/Plugins/Registry/DownloadCountService.swift @@ -84,8 +84,7 @@ final class DownloadCountService { private func buildTagPrefixMap(from manifest: RegistryManifest) -> [String: String] { var map: [String: String] = [:] for plugin in manifest.plugins { - let url = plugin.binaries?.first?.downloadURL ?? plugin.downloadURL - guard let url else { continue } + guard let url = plugin.binaries.first?.downloadURL else { continue } guard let tagComponent = extractTagComponent(from: url) else { continue } let prefix = extractTagPrefix(from: tagComponent) map[prefix] = plugin.id diff --git a/TablePro/Core/Plugins/Registry/PluginInstallTracker.swift b/TablePro/Core/Plugins/Registry/PluginInstallTracker.swift index caf8c8b91..6663c3a82 100644 --- a/TablePro/Core/Plugins/Registry/PluginInstallTracker.swift +++ b/TablePro/Core/Plugins/Registry/PluginInstallTracker.swift @@ -27,16 +27,30 @@ final class PluginInstallTracker { func completeInstall(pluginId: String) { activeInstalls[pluginId]?.phase = .completed - Task { + Task { [weak self] in try? await Task.sleep(for: .seconds(3)) - if case .completed = self.activeInstalls[pluginId]?.phase { - self.activeInstalls.removeValue(forKey: pluginId) + if case .completed = self?.activeInstalls[pluginId]?.phase { + self?.activeInstalls.removeValue(forKey: pluginId) } } } func failInstall(pluginId: String, error: String) { activeInstalls[pluginId]?.phase = .failed(error) + Task { [weak self] in + try? await Task.sleep(for: .seconds(30)) + if case .failed = self?.activeInstalls[pluginId]?.phase { + self?.activeInstalls.removeValue(forKey: pluginId) + } + } + } + + func markStaged(pluginId: String, newVersion: String) { + if activeInstalls[pluginId] == nil { + activeInstalls[pluginId] = InstallProgress(phase: .stagedPendingActivation(newVersion: newVersion)) + } else { + activeInstalls[pluginId]?.phase = .stagedPendingActivation(newVersion: newVersion) + } } func clearInstall(pluginId: String) { @@ -54,6 +68,7 @@ struct InstallProgress: Equatable { enum Phase: Equatable { case downloading(fraction: Double) case installing + case stagedPendingActivation(newVersion: String) case completed case failed(String) } diff --git a/TablePro/Core/Plugins/Registry/PluginManager+Registry.swift b/TablePro/Core/Plugins/Registry/PluginManager+Registry.swift deleted file mode 100644 index aaf57e8e8..000000000 --- a/TablePro/Core/Plugins/Registry/PluginManager+Registry.swift +++ /dev/null @@ -1,117 +0,0 @@ -// -// PluginManager+Registry.swift -// TablePro -// - -import CryptoKit -import Foundation - -extension PluginManager { - func installFromRegistry( - _ registryPlugin: RegistryPlugin, - progress: @escaping @MainActor @Sendable (Double) -> Void - ) async throws -> PluginEntry { - guard !isInstalling else { - throw PluginError.installFailed("Another plugin installation is already in progress") - } - isInstalling = true - defer { isInstalling = false } - - try validateRegistryCompatibility(registryPlugin) - - if plugins.contains(where: { $0.id == registryPlugin.id }) { - throw PluginError.pluginConflict(existingName: registryPlugin.name) - } - - return try await downloadAndInstall(registryPlugin, progress: progress) - } - - func updateFromRegistry( - _ registryPlugin: RegistryPlugin, - existingPluginLoaded: Bool = true, - progress: @escaping @MainActor @Sendable (Double) -> Void - ) async throws -> PluginEntry { - guard !isInstalling else { - throw PluginError.installFailed("Another plugin installation is already in progress") - } - isInstalling = true - defer { isInstalling = false } - - try validateRegistryCompatibility(registryPlugin) - - replaceExistingPlugin(bundleId: registryPlugin.id) - - let entry = try await downloadAndInstall(registryPlugin, progress: progress) - - if existingPluginLoaded { - needsRestart = true - } - - return entry - } - - private func validateRegistryCompatibility(_ registryPlugin: RegistryPlugin) throws { - if let minAppVersion = registryPlugin.minAppVersion { - let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" - if appVersion.compare(minAppVersion, options: .numeric) == .orderedAscending { - throw PluginError.incompatibleWithCurrentApp(minimumRequired: minAppVersion) - } - } - - if let minKit = registryPlugin.minPluginKitVersion, minKit > Self.currentPluginKitVersion { - throw PluginError.incompatibleVersion(required: minKit, current: Self.currentPluginKitVersion) - } - } - - private func downloadAndInstall( - _ registryPlugin: RegistryPlugin, - progress: @escaping @MainActor @Sendable (Double) -> Void - ) async throws -> PluginEntry { - let resolved = try registryPlugin.resolvedBinary() - - guard let downloadURL = URL(string: resolved.url) else { - throw PluginError.downloadFailed("Invalid download URL") - } - - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - let tempZipURL = tempDir.appendingPathComponent("\(registryPlugin.id).zip") - - defer { - try? FileManager.default.removeItem(at: tempDir) - } - - let session = RegistryClient.shared.session - let (tempDownloadURL, response) = try await session.download(from: downloadURL) - - guard let httpResponse = response as? HTTPURLResponse, - (200...299).contains(httpResponse.statusCode) else { - let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 - throw PluginError.downloadFailed("HTTP \(statusCode)") - } - - progress(0.5) - - let downloadedData = try Data(contentsOf: tempDownloadURL) - let digest = SHA256.hash(data: downloadedData) - let hexChecksum = digest.map { String(format: "%02x", $0) }.joined() - - if hexChecksum != resolved.sha256.lowercased() { - throw PluginError.checksumMismatch - } - - progress(1.0) - - try FileManager.default.moveItem(at: tempDownloadURL, to: tempZipURL) - - let entry = try await performInstallAssumingLock(from: tempZipURL) - - saveRegistryMetadata( - pluginId: registryPlugin.id, - pluginURL: entry.url - ) - - return entry - } -} diff --git a/TablePro/Core/Plugins/Registry/RegistryClient.swift b/TablePro/Core/Plugins/Registry/RegistryClient.swift index 674ccf210..e69172072 100644 --- a/TablePro/Core/Plugins/Registry/RegistryClient.swift +++ b/TablePro/Core/Plugins/Registry/RegistryClient.swift @@ -20,6 +20,7 @@ final class RegistryClient { } let session: URLSession + static let supportedSchemaVersion = 2 private static let logger = Logger(subsystem: "com.TablePro", category: "RegistryClient") private static let defaultRegistryURL = URL(string: @@ -138,6 +139,17 @@ final class RegistryClient { case 200...299: let decoded = try JSONDecoder().decode(RegistryManifest.self, from: data) + + if decoded.schemaVersion > Self.supportedSchemaVersion { + Self.logger.error( + "Registry schemaVersion \(decoded.schemaVersion) is newer than supported \(Self.supportedSchemaVersion); falling back to cached manifest" + ) + fallbackToCacheOrFail( + message: String(localized: "Plugin registry requires a newer app version") + ) + return + } + manifest = decoded Self.writeCachedManifest(data) diff --git a/TablePro/Core/Plugins/Registry/RegistryModels.swift b/TablePro/Core/Plugins/Registry/RegistryModels.swift index fb617458d..8d9784dd2 100644 --- a/TablePro/Core/Plugins/Registry/RegistryModels.swift +++ b/TablePro/Core/Plugins/Registry/RegistryModels.swift @@ -22,6 +22,7 @@ struct RegistryBinary: Codable, Sendable { let architecture: PluginArchitecture let downloadURL: String let sha256: String + let pluginKitVersion: Int? } struct RegistryManifest: Codable, Sendable { @@ -38,23 +39,90 @@ struct RegistryPlugin: Codable, Sendable, Identifiable { let homepage: String? let category: RegistryCategory let databaseTypeIds: [String]? - let downloadURL: String? - let sha256: String? - let binaries: [RegistryBinary]? + let binaries: [RegistryBinary] let minAppVersion: String? - let minPluginKitVersion: Int? let iconName: String? let isVerified: Bool let metadata: RegistryPluginMetadata? + + private let legacyDownloadURL: String? + private let legacySha256: String? + private let legacyMinPluginKitVersion: Int? + + private enum CodingKeys: String, CodingKey { + case id, name, version, summary, author, homepage, category + case databaseTypeIds, binaries, minAppVersion, iconName, isVerified, metadata + case legacyDownloadURL = "downloadURL" + case legacySha256 = "sha256" + case legacyMinPluginKitVersion = "minPluginKitVersion" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + version = try container.decode(String.self, forKey: .version) + summary = try container.decode(String.self, forKey: .summary) + author = try container.decode(RegistryAuthor.self, forKey: .author) + homepage = try container.decodeIfPresent(String.self, forKey: .homepage) + category = try container.decode(RegistryCategory.self, forKey: .category) + databaseTypeIds = try container.decodeIfPresent([String].self, forKey: .databaseTypeIds) + minAppVersion = try container.decodeIfPresent(String.self, forKey: .minAppVersion) + iconName = try container.decodeIfPresent(String.self, forKey: .iconName) + isVerified = try container.decodeIfPresent(Bool.self, forKey: .isVerified) ?? false + metadata = try container.decodeIfPresent(RegistryPluginMetadata.self, forKey: .metadata) + legacyDownloadURL = try container.decodeIfPresent(String.self, forKey: .legacyDownloadURL) + legacySha256 = try container.decodeIfPresent(String.self, forKey: .legacySha256) + legacyMinPluginKitVersion = try container.decodeIfPresent(Int.self, forKey: .legacyMinPluginKitVersion) + + if let decodedBinaries = try container.decodeIfPresent([RegistryBinary].self, forKey: .binaries) { + binaries = decodedBinaries + } else if let url = legacyDownloadURL, let hash = legacySha256 { + // v1 manifests carried a single downloadURL with no architecture. + // Historically those ZIPs shipped a universal binary, so we synthesize + // entries for both architectures. Code signature verification will + // reject mismatched arch at install time. + binaries = [ + RegistryBinary( + architecture: .arm64, + downloadURL: url, + sha256: hash, + pluginKitVersion: legacyMinPluginKitVersion + ), + RegistryBinary( + architecture: .x86_64, + downloadURL: url, + sha256: hash, + pluginKitVersion: legacyMinPluginKitVersion + ) + ] + } else { + binaries = [] + } + } } extension RegistryPlugin { - func resolvedBinary(for arch: PluginArchitecture = .current) throws -> (url: String, sha256: String) { - if let binaries, let match = binaries.first(where: { $0.architecture == arch }) { - return (match.downloadURL, match.sha256) + func resolvedBinary( + for arch: PluginArchitecture = .current, + pluginKitVersion: Int + ) throws -> RegistryBinary { + let archMatches = binaries.filter { $0.architecture == arch } + + if let exact = archMatches.first(where: { $0.pluginKitVersion == pluginKitVersion }) { + return exact + } + + throw PluginError.noCompatibleBinary + } + + // Themes carry no native code, so PluginKit ABI does not apply; match on architecture only. + func resolvedThemeBinary(for arch: PluginArchitecture = .current) throws -> RegistryBinary { + if let match = binaries.first(where: { $0.architecture == arch }) { + return match } - if let url = downloadURL, let hash = sha256 { - return (url, hash) + if let any = binaries.first { + return any } throw PluginError.noCompatibleBinary } diff --git a/TablePro/Core/Services/Notifications/PluginNotificationService.swift b/TablePro/Core/Services/Notifications/PluginNotificationService.swift new file mode 100644 index 000000000..59a73c852 --- /dev/null +++ b/TablePro/Core/Services/Notifications/PluginNotificationService.swift @@ -0,0 +1,126 @@ +// +// PluginNotificationService.swift +// TablePro +// + +import Combine +import Foundation +import os +import UserNotifications + +@MainActor @Observable +final class PluginNotificationService { + static let shared = PluginNotificationService() + + static let identifierPrefix = "com.TablePro.plugin." + static let openPluginSettingsActionId = "openPluginSettings" + private static let updateFailedCategoryId = "com.TablePro.pluginUpdateFailed" + private static let failedIdentifierPrefix = identifierPrefix + "failed." + private static let logger = Logger(subsystem: "com.TablePro", category: "PluginNotifications") + + private(set) var authorizationStatus: UNAuthorizationStatus = .notDetermined + + @ObservationIgnored private var cancellables: Set = [] + @ObservationIgnored private var didRequestPermission = false + @ObservationIgnored private var deliveredFailureIdentifiers: Set = [] + + private init() {} + + func setUp() { + registerCategories() + subscribeToEvents() + Task { await refreshAuthorizationStatus() } + } + + func requestPermissionIfNeeded() async { + await refreshAuthorizationStatus() + guard authorizationStatus == .notDetermined, !didRequestPermission else { return } + didRequestPermission = true + do { + let granted = try await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .badge]) + Self.logger.info("Notification permission \(granted ? "granted" : "denied")") + } catch { + Self.logger.error("Notification permission request failed: \(error.localizedDescription)") + } + await refreshAuthorizationStatus() + } + + func notifyAutoUpdateFailed(plugins: [RejectedPlugin]) async { + guard !plugins.isEmpty else { return } + await requestPermissionIfNeeded() + guard authorizationStatus == .authorized else { return } + + let center = UNUserNotificationCenter.current() + let incomingIdentifiers = Set(plugins.map(Self.failureIdentifier)) + let staleIdentifiers = deliveredFailureIdentifiers.subtracting(incomingIdentifiers) + if !staleIdentifiers.isEmpty { + center.removeDeliveredNotifications(withIdentifiers: Array(staleIdentifiers)) + deliveredFailureIdentifiers.subtract(staleIdentifiers) + } + + for plugin in plugins { + let content = UNMutableNotificationContent() + content.title = String(format: String(localized: "%@ could not be loaded"), plugin.name) + content.body = plugin.reason + content.categoryIdentifier = Self.updateFailedCategoryId + + let identifier = Self.failureIdentifier(for: plugin) + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil) + do { + try await center.add(request) + deliveredFailureIdentifiers.insert(identifier) + } catch { + Self.logger.error("Failed to post notification for '\(plugin.name)': \(error.localizedDescription)") + } + } + } + + func clearPluginNotifications() async { + let center = UNUserNotificationCenter.current() + guard !deliveredFailureIdentifiers.isEmpty else { return } + center.removeDeliveredNotifications(withIdentifiers: Array(deliveredFailureIdentifiers)) + deliveredFailureIdentifiers.removeAll() + } + + private static func failureIdentifier(for plugin: RejectedPlugin) -> String { + let key = plugin.bundleId ?? plugin.registryId ?? plugin.name + return failedIdentifierPrefix + key + } + + private func registerCategories() { + let openAction = UNNotificationAction( + identifier: Self.openPluginSettingsActionId, + title: String(localized: "Open Plugin Settings"), + options: [.foreground] + ) + let failedCategory = UNNotificationCategory( + identifier: Self.updateFailedCategoryId, + actions: [openAction], + intentIdentifiers: [], + options: [] + ) + UNUserNotificationCenter.current().setNotificationCategories([failedCategory]) + } + + private func subscribeToEvents() { + AppEvents.shared.pluginsRejected + .receive(on: RunLoop.main) + .sink { [weak self] rejected in + guard let self else { return } + Task { + if rejected.isEmpty { + await self.clearPluginNotifications() + } else { + await self.notifyAutoUpdateFailed(plugins: rejected) + } + } + } + .store(in: &cancellables) + } + + private func refreshAuthorizationStatus() async { + let settings = await UNUserNotificationCenter.current().notificationSettings() + authorizationStatus = settings.authorizationStatus + } +} diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index bc1c9fd34..e011dc70b 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -943,6 +943,9 @@ } } } + }, + "%@ could not be loaded" : { + }, "%@ does not support switching schemas in TablePro." : { @@ -1674,6 +1677,7 @@ } }, "%d plugin(s) could not be loaded" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -1694,6 +1698,9 @@ } } } + }, + "%d plugins could not be loaded." : { + }, "%d rows" : { "localizations" : { @@ -2883,6 +2890,9 @@ } } } + }, + "1 plugin could not be loaded." : { + }, "1 year" : { "localizations" : { @@ -3878,6 +3888,12 @@ } } } + }, + "Activate next time you launch TablePro" : { + + }, + "Activate Now" : { + }, "Activation Failed" : { "localizations" : { @@ -6039,6 +6055,9 @@ } } } + }, + "Another install is already in progress for this plugin" : { + }, "Any Column" : { "localizations" : { @@ -9905,6 +9924,9 @@ } } } + }, + "Close active connections to apply." : { + }, "Close Others" : { "localizations" : { @@ -16520,6 +16542,9 @@ } } } + }, + "Driver plugin not loaded. Open Settings to update." : { + }, "Drop" : { "localizations" : { @@ -24484,6 +24509,7 @@ } }, "Incompatible plugin version" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -32470,6 +32496,9 @@ }, "On Disk" : { + }, + "On Quit" : { + }, "On Update" : { "localizations" : { @@ -33104,6 +33133,9 @@ } } } + }, + "Operation Failed" : { + }, "Operator" : { "localizations" : { @@ -34518,6 +34550,9 @@ } } } + }, + "Plugin has active connections" : { + }, "Plugin Installation Failed" : { "localizations" : { @@ -34606,6 +34641,22 @@ } } } + }, + "Plugin not loaded" : { + + }, + "Plugin not loaded: %@. %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Plugin not loaded: %1$@. %2$@" + } + } + } + }, + "Plugin registry requires a newer app version" : { + }, "Plugin requires app version %@ or later, but current version is %@" : { "localizations" : { @@ -37956,6 +38007,9 @@ }, "Remove “%@”?" : { + }, + "Remove %@" : { + }, "Remove %@?" : { "localizations" : { @@ -38003,6 +38057,9 @@ }, "Remove attachment" : { + }, + "Remove Failed" : { + }, "Remove filter" : { "localizations" : { @@ -47055,6 +47112,7 @@ } }, "The following plugins were rejected:\n\n%@\n\nYou can update them from the plugin registry in Settings." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -47275,6 +47333,9 @@ } } } + }, + "Theme Update Failed" : { + }, "Theme:" : { "localizations" : { @@ -50412,6 +50473,18 @@ } } } + }, + "Update %@" : { + + }, + "Update %@ plugin" : { + + }, + "Update Now" : { + + }, + "Update Plugin" : { + }, "UPDATE Statement(s)" : { "localizations" : { @@ -51003,6 +51076,9 @@ } } } + }, + "v%@ ready to activate" : { + }, "v%@+" : { "localizations" : { diff --git a/TablePro/Theme/ThemeEngine.swift b/TablePro/Theme/ThemeEngine.swift index b6657e765..4fd55ffcc 100644 --- a/TablePro/Theme/ThemeEngine.swift +++ b/TablePro/Theme/ThemeEngine.swift @@ -125,14 +125,14 @@ internal final class ThemeEngine { // MARK: - Theme Lifecycle func activateTheme(id: String) { - guard let theme = availableThemes.first(where: { $0.id == id }) - ?? ThemeStorage.loadTheme(id: id) - else { - Self.logger.warning("Theme not found: \(id)") + if let theme = availableThemes.first(where: { $0.id == id }) + ?? ThemeStorage.loadTheme(id: id) { + activateTheme(theme) return } - activateTheme(theme) + Self.logger.warning("Theme '\(id)' not found; falling back to default") + activateTheme(.default) } func activateTheme(_ theme: ThemeDefinition) { diff --git a/TablePro/Theme/ThemeRegistryInstaller.swift b/TablePro/Theme/ThemeRegistryInstaller.swift index 4927d978b..75aca2d4e 100644 --- a/TablePro/Theme/ThemeRegistryInstaller.swift +++ b/TablePro/Theme/ThemeRegistryInstaller.swift @@ -186,9 +186,9 @@ internal final class ThemeRegistryInstaller { } } - let resolved = try plugin.resolvedBinary() + let resolved = try plugin.resolvedThemeBinary(for: .current) - guard let downloadURL = URL(string: resolved.url) else { + guard let downloadURL = URL(string: resolved.downloadURL) else { throw PluginError.downloadFailed("Invalid download URL") } @@ -238,11 +238,17 @@ internal final class ThemeRegistryInstaller { } }.value + PluginInstaller.stripQuarantine(at: extractDir) + let jsonFiles = try findJsonFiles(in: extractDir) guard !jsonFiles.isEmpty else { throw PluginError.installFailed("No theme files found in archive") } + for jsonFile in jsonFiles { + PluginInstaller.stripQuarantine(at: jsonFile) + } + progress(0.9) let decoder = JSONDecoder() diff --git a/TablePro/Theme/ThemeStorage.swift b/TablePro/Theme/ThemeStorage.swift index 6f0169cdc..1f271c940 100644 --- a/TablePro/Theme/ThemeStorage.swift +++ b/TablePro/Theme/ThemeStorage.swift @@ -64,23 +64,29 @@ internal struct ThemeStorage { // MARK: - Load Single Theme static func loadTheme(id: String) -> ThemeDefinition? { - guard let userFile = try? themeFileURL(in: userThemesDirectory, id: id) else { return nil } - if let theme = loadTheme(from: userFile) { + let fm = FileManager.default + + if let userFile = try? themeFileURL(in: userThemesDirectory, id: id), + fm.fileExists(atPath: userFile.path), + let theme = loadTheme(from: userFile) { return theme } if let registryFile = try? themeFileURL(in: registryThemesDirectory, id: id), + fm.fileExists(atPath: registryFile.path), let theme = loadTheme(from: registryFile) { return theme } - if let bundleDir = bundledThemesDirectory, + // User themes are never bundled; skip the bundle search for them. + if !id.hasPrefix("user."), + let bundleDir = bundledThemesDirectory, let bundleFile = try? themeFileURL(in: bundleDir, id: id), + fm.fileExists(atPath: bundleFile.path), let theme = loadTheme(from: bundleFile) { return theme } - // Fallback to compiled presets return id == ThemeDefinition.default.id ? .default : nil } @@ -249,6 +255,8 @@ internal struct ThemeStorage { do { let data = try Data(contentsOf: url) return try JSONDecoder().decode(ThemeDefinition.self, from: data) + } catch CocoaError.fileNoSuchFile, CocoaError.fileReadNoSuchFile { + return nil } catch { logger.error("Failed to load theme from \(url.lastPathComponent): \(error)") return nil diff --git a/TablePro/Views/Connection/PluginRejectedBannerModifier.swift b/TablePro/Views/Connection/PluginRejectedBannerModifier.swift new file mode 100644 index 000000000..03ead5d19 --- /dev/null +++ b/TablePro/Views/Connection/PluginRejectedBannerModifier.swift @@ -0,0 +1,123 @@ +// +// PluginRejectedBannerModifier.swift +// TablePro +// + +import SwiftUI + +struct PluginRejectedBannerModifier: ViewModifier { + let databaseType: DatabaseType + private let pluginManager = PluginManager.shared + private let registryClient = RegistryClient.shared + private let installTracker = PluginInstallTracker.shared + + @State private var errorMessage: String? + @State private var showError = false + + private var rejectedPlugin: RejectedPlugin? { + let typeId = databaseType.pluginTypeId + return pluginManager.rejectedPlugins.first { rejected in + rejected.bundleId == typeId || rejected.registryId == typeId + } + } + + private var registryEntry: RegistryPlugin? { + guard let rejected = rejectedPlugin else { return nil } + return pluginManager.registryPlugin(for: rejected) + } + + func body(content: Content) -> some View { + VStack(spacing: 0) { + if let plugin = rejectedPlugin { + banner(for: plugin) + Divider() + } + content + } + .alert(String(localized: "Plugin Update Failed"), isPresented: $showError) { + Button(String(localized: "OK")) {} + } message: { + Text(errorMessage ?? "") + } + } + + @ViewBuilder + private func banner(for plugin: RejectedPlugin) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + .font(.body) + + VStack(alignment: .leading, spacing: 4) { + Text(String(localized: "Plugin not loaded")) + .font(.callout.weight(.semibold)) + Text(plugin.reason) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 8) + + if let registryPlugin = registryEntry { + progressOrButton(registryPlugin: registryPlugin) + } + } + .padding(12) + .background(Color.yellow.opacity(0.08)) + .accessibilityElement(children: .contain) + .accessibilityLabel( + String(format: String(localized: "Plugin not loaded: %@. %@"), plugin.name, plugin.reason) + ) + } + + @ViewBuilder + private func progressOrButton(registryPlugin: RegistryPlugin) -> some View { + if let progress = installTracker.state(for: registryPlugin.id) { + switch progress.phase { + case .downloading(let fraction): + ProgressView(value: fraction) + .frame(width: 60) + .progressViewStyle(.linear) + case .installing: + ProgressView().controlSize(.small) + case .stagedPendingActivation: + Image(systemName: "clock.arrow.circlepath") + .foregroundStyle(.orange) + case .completed: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + case .failed: + updateButton(registryPlugin: registryPlugin) + } + } else { + updateButton(registryPlugin: registryPlugin) + } + } + + @ViewBuilder + private func updateButton(registryPlugin: RegistryPlugin) -> some View { + Button(String(localized: "Update Plugin")) { + triggerUpdate(registryPlugin) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .accessibilityLabel(String(format: String(localized: "Update %@ plugin"), registryPlugin.name)) + } + + private func triggerUpdate(_ registryPlugin: RegistryPlugin) { + Task { + let result = await pluginManager.performRegistryUpdate(registryPlugin) + if case .failed(let error) = result { + errorMessage = error.localizedDescription + showError = true + } + } + } +} + +extension View { + func pluginRejectedBanner(for databaseType: DatabaseType) -> some View { + modifier(PluginRejectedBannerModifier(databaseType: databaseType)) + } +} diff --git a/TablePro/Views/Connection/WelcomeConnectionRow.swift b/TablePro/Views/Connection/WelcomeConnectionRow.swift index 7833d995b..ba8261b60 100644 --- a/TablePro/Views/Connection/WelcomeConnectionRow.swift +++ b/TablePro/Views/Connection/WelcomeConnectionRow.swift @@ -8,6 +8,7 @@ import SwiftUI struct WelcomeConnectionRow: View { let connection: DatabaseConnection let sshProfile: SSHProfile? + private let pluginManager = PluginManager.shared private var displayTag: ConnectionTag? { guard let tagId = connection.tagId else { return nil } @@ -18,6 +19,13 @@ struct WelcomeConnectionRow: View { connection.localOnly && !connection.isSample } + private var isDriverRejected: Bool { + let typeId = connection.type.pluginTypeId + return pluginManager.rejectedPlugins.contains { rejected in + rejected.bundleId == typeId || rejected.registryId == typeId + } + } + var body: some View { HStack { connection.type.iconImage @@ -50,6 +58,14 @@ struct WelcomeConnectionRow: View { @ViewBuilder private var trailingAccessories: some View { HStack(spacing: 8) { + if isDriverRejected { + Image(systemName: "exclamationmark.triangle.fill") + .imageScale(.small) + .foregroundStyle(.yellow) + .help(String(localized: "Driver plugin not loaded. Open Settings to update.")) + .accessibilityLabel(String(localized: "Plugin not loaded")) + } + if showsLocalOnly { Image(systemName: "icloud.slash") .imageScale(.small) diff --git a/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift b/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift index 1a92a2467..76d3153c1 100644 --- a/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift +++ b/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift @@ -15,6 +15,7 @@ struct BrowsePluginsView: View { @State private var selectedCategory: RegistryCategory? @State private var selectedPluginId: String? @State private var showErrorAlert = false + @State private var errorTitle = String(localized: "Operation Failed") @State private var errorMessage = "" private var selectedRegistryPlugin: RegistryPlugin? { @@ -30,7 +31,7 @@ struct BrowsePluginsView: View { } await downloadCountService.fetchCounts(for: registryClient.manifest) } - .alert(String(localized: "Installation Failed"), isPresented: $showErrorAlert) { + .alert(errorTitle, isPresented: $showErrorAlert) { Button("OK") {} } message: { Text(errorMessage) @@ -162,6 +163,10 @@ struct BrowsePluginsView: View { case .installing: ProgressView() .controlSize(.mini) + case .stagedPendingActivation: + Image(systemName: "clock.arrow.circlepath") + .foregroundStyle(.orange) + .font(.caption) case .completed: Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) @@ -189,6 +194,10 @@ struct BrowsePluginsView: View { case .installing: ProgressView() .controlSize(.mini) + case .stagedPendingActivation: + Image(systemName: "clock.arrow.circlepath") + .foregroundStyle(.orange) + .font(.caption) case .completed: Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) @@ -261,11 +270,31 @@ struct BrowsePluginsView: View { } private func updatePlugin(_ plugin: RegistryPlugin) { - performTrackedOperation(pluginId: plugin.id) { progress in + Task { if plugin.category == .theme { - try await ThemeRegistryInstaller.shared.update(plugin, progress: progress) - } else { - _ = try await pluginManager.updateFromRegistry(plugin, progress: progress) + installTracker.beginInstall(pluginId: plugin.id) + do { + try await ThemeRegistryInstaller.shared.update(plugin) { fraction in + installTracker.updateProgress(pluginId: plugin.id, fraction: fraction) + if fraction >= 1.0 { + installTracker.markInstalling(pluginId: plugin.id) + } + } + installTracker.completeInstall(pluginId: plugin.id) + } catch { + installTracker.failInstall(pluginId: plugin.id, error: error.localizedDescription) + errorTitle = String(localized: "Theme Update Failed") + errorMessage = error.localizedDescription + showErrorAlert = true + } + return + } + + let result = await pluginManager.performRegistryUpdate(plugin) + if case .failed(let error) = result { + errorTitle = String(localized: "Plugin Update Failed") + errorMessage = error.localizedDescription + showErrorAlert = true } } } @@ -286,6 +315,7 @@ struct BrowsePluginsView: View { installTracker.completeInstall(pluginId: pluginId) } catch { installTracker.failInstall(pluginId: pluginId, error: error.localizedDescription) + errorTitle = String(localized: "Installation Failed") errorMessage = error.localizedDescription showErrorAlert = true } diff --git a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index 6f470de72..cbcb74a5b 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -18,6 +18,7 @@ struct InstalledPluginsView: View { @State private var errorAlertTitle = "" @State private var errorAlertMessage = "" @State private var dismissedRestartBanner = false + @State private var dismissedRejectedBanner = false private var filteredPlugins: [PluginEntry] { if searchText.isEmpty { return pluginManager.plugins } @@ -26,6 +27,9 @@ struct InstalledPluginsView: View { var body: some View { VStack(spacing: 0) { + if !pluginManager.rejectedPlugins.isEmpty && !dismissedRejectedBanner { + rejectedPluginsBanner + } if pluginManager.needsRestart && !dismissedRestartBanner { restartBanner } @@ -86,6 +90,88 @@ struct InstalledPluginsView: View { .padding(.vertical, 6) } + // MARK: - Rejected Plugins Banner + + private var rejectedPluginsBanner: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.circle.fill") + .foregroundStyle(.red) + Text(pluginManager.rejectedPlugins.count == 1 + ? String(localized: "1 plugin could not be loaded.") + : String(format: String(localized: "%d plugins could not be loaded."), + pluginManager.rejectedPlugins.count)) + .font(.callout.weight(.medium)) + Spacer() + Button(String(localized: "Dismiss")) { dismissedRejectedBanner = true } + .buttonStyle(.borderless) + .font(.callout) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + + ForEach(pluginManager.rejectedPlugins, id: \.url) { plugin in + rejectedPluginRow(plugin) + Divider().padding(.leading, 12) + } + } + } + + @ViewBuilder + private func rejectedPluginRow(_ plugin: RejectedPlugin) -> some View { + HStack(spacing: 8) { + Image(systemName: "puzzlepiece") + .frame(width: 24, height: 24) + .foregroundStyle(.tertiary) + VStack(alignment: .leading, spacing: 2) { + Text(plugin.name) + .font(.callout.weight(.medium)) + .lineLimit(1) + Text(plugin.reason) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer() + if let registryPlugin = registryEntry(for: plugin) { + Button(String(localized: "Update Now")) { + updatePlugin(registryPlugin) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .accessibilityLabel(String(format: String(localized: "Update %@"), plugin.name)) + } + Button(String(localized: "Remove")) { + removeRejectedPlugin(plugin) + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityLabel(String(format: String(localized: "Remove %@"), plugin.name)) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + + private func registryEntry(for plugin: RejectedPlugin) -> RegistryPlugin? { + pluginManager.registryPlugin(for: plugin) + } + + private func removeRejectedPlugin(_ plugin: RejectedPlugin) { + let fm = FileManager.default + if fm.fileExists(atPath: plugin.url.path) { + do { + try fm.removeItem(at: plugin.url) + } catch CocoaError.fileNoSuchFile { + } catch { + errorAlertTitle = String(localized: "Remove Failed") + errorAlertMessage = error.localizedDescription + showErrorAlert = true + return + } + } + pluginManager.removeFromRejected(url: plugin.url) + } + private func relaunchApp() { let bundleURL = Bundle.main.bundleURL let configuration = NSWorkspace.OpenConfiguration() @@ -346,6 +432,30 @@ struct InstalledPluginsView: View { .font(.callout) .foregroundStyle(.secondary) } + case .stagedPendingActivation(let newVersion): + HStack(spacing: 8) { + Image(systemName: "clock.arrow.circlepath") + .foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 2) { + Text(String(format: String(localized: "v%@ ready to activate"), newVersion)) + .font(.callout) + Text(String(localized: "Close active connections to apply.")) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button(String(localized: "Activate Now")) { + activateStagedPlugin(plugin) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + Button(String(localized: "On Quit")) { + installTracker.clearInstall(pluginId: plugin.id) + } + .buttonStyle(.bordered) + .controlSize(.small) + .help(String(localized: "Activate next time you launch TablePro")) + } case .completed: Label( String(format: String(localized: "Updated to v%@"), registryPlugin.version), @@ -372,18 +482,21 @@ struct InstalledPluginsView: View { private func updatePlugin(_ registryPlugin: RegistryPlugin) { Task { - installTracker.beginInstall(pluginId: registryPlugin.id) + let result = await pluginManager.performRegistryUpdate(registryPlugin) + if case .failed(let error) = result { + errorAlertTitle = String(localized: "Plugin Update Failed") + errorAlertMessage = error.localizedDescription + showErrorAlert = true + } + } + } + + private func activateStagedPlugin(_ plugin: PluginEntry) { + Task { do { - _ = try await pluginManager.updateFromRegistry(registryPlugin) { fraction in - installTracker.updateProgress(pluginId: registryPlugin.id, fraction: fraction) - if fraction >= 1.0 { - installTracker.markInstalling(pluginId: registryPlugin.id) - } - } - installTracker.completeInstall(pluginId: registryPlugin.id) + _ = try await pluginManager.commitStagedUpdate(pluginId: plugin.id) } catch { - installTracker.failInstall(pluginId: registryPlugin.id, error: error.localizedDescription) - errorAlertTitle = String(localized: "Plugin Update Failed") + errorAlertTitle = String(localized: "Activation Failed") errorAlertMessage = error.localizedDescription showErrorAlert = true } @@ -432,7 +545,7 @@ struct InstalledPluginsView: View { guard confirmed else { return } do { - try pluginManager.uninstallPlugin(id: plugin.id) + try await pluginManager.uninstallPlugin(id: plugin.id) selectedPluginId = nil } catch { errorAlertTitle = String(localized: "Uninstall Failed") diff --git a/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift b/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift index 24be02836..f1746c850 100644 --- a/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift +++ b/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift @@ -123,6 +123,13 @@ struct RegistryPluginDetailView: View { .font(.callout) .foregroundStyle(.secondary) } + case .stagedPendingActivation(let newVersion): + Label( + String(format: String(localized: "v%@ ready to activate"), newVersion), + systemImage: "clock.arrow.circlepath" + ) + .foregroundStyle(.orange) + .font(.callout) case .completed: Label("Installed", systemImage: "checkmark.circle.fill") .foregroundStyle(.green) @@ -162,6 +169,13 @@ struct RegistryPluginDetailView: View { .font(.callout) .foregroundStyle(.secondary) } + case .stagedPendingActivation(let newVersion): + Label( + String(format: String(localized: "v%@ ready to activate"), newVersion), + systemImage: "clock.arrow.circlepath" + ) + .foregroundStyle(.orange) + .font(.callout) case .completed: Label("Updated", systemImage: "checkmark.circle.fill") .foregroundStyle(.green) diff --git a/TablePro/Views/Settings/SettingsView.swift b/TablePro/Views/Settings/SettingsView.swift index 38c42f135..22723ea7a 100644 --- a/TablePro/Views/Settings/SettingsView.swift +++ b/TablePro/Views/Settings/SettingsView.swift @@ -13,6 +13,11 @@ struct SettingsView: View { @Bindable private var settingsManager = AppSettingsManager.shared @Environment(UpdaterBridge.self) var updaterBridge @AppStorage("selectedSettingsTab") private var selectedTab: String = SettingsTab.general.rawValue + private let pluginManager = PluginManager.shared + + private var pluginAttentionCount: Int { + pluginManager.rejectedPlugins.count + pluginManager.pluginsWithRegistryUpdate.count + } var body: some View { TabView(selection: $selectedTab) { @@ -55,6 +60,7 @@ struct SettingsView: View { PluginsSettingsView() .tabItem { Label("Plugins", systemImage: "puzzlepiece.extension") } + .badge(pluginAttentionCount) .tag(SettingsTab.plugins.rawValue) AccountSettingsView() diff --git a/TableProTests/Core/Plugins/NeedsRestartPersistenceTests.swift b/TableProTests/Core/Plugins/NeedsRestartPersistenceTests.swift index d9e064660..256b33645 100644 --- a/TableProTests/Core/Plugins/NeedsRestartPersistenceTests.swift +++ b/TableProTests/Core/Plugins/NeedsRestartPersistenceTests.swift @@ -22,8 +22,8 @@ struct NeedsRestartPersistenceTests { } @Test("loadPendingPlugins with no pending plugins does not set needsRestart") - func loadPendingPluginsKeepsFalse() { - PluginManager.shared.loadPendingPlugins() + func loadPendingPluginsKeepsFalse() async { + await PluginManager.shared.loadPendingPluginsAsync() #expect(PluginManager.shared.needsRestart == false) } diff --git a/TableProTests/Core/Plugins/PluginInstallTrackerStagedTests.swift b/TableProTests/Core/Plugins/PluginInstallTrackerStagedTests.swift new file mode 100644 index 000000000..94c56e336 --- /dev/null +++ b/TableProTests/Core/Plugins/PluginInstallTrackerStagedTests.swift @@ -0,0 +1,54 @@ +// +// PluginInstallTrackerStagedTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("PluginInstallTracker staged phase", .serialized) +@MainActor +struct PluginInstallTrackerStagedTests { + + private let pluginId = "com.example.staged.test" + + private func cleanup() { + PluginInstallTracker.shared.clearInstall(pluginId: pluginId) + } + + @Test("markStaged transitions phase to stagedPendingActivation") + func markStagedSetsPhase() { + defer { cleanup() } + let tracker = PluginInstallTracker.shared + tracker.beginInstall(pluginId: pluginId) + tracker.markStaged(pluginId: pluginId, newVersion: "1.0.23") + let phase = tracker.state(for: pluginId)?.phase + if case .stagedPendingActivation(let version) = phase { + #expect(version == "1.0.23") + } else { + Issue.record("Expected stagedPendingActivation, got \(String(describing: phase))") + } + } + + @Test("markStaged creates entry even when no prior beginInstall") + func markStagedCreatesEntry() { + defer { cleanup() } + let tracker = PluginInstallTracker.shared + tracker.markStaged(pluginId: pluginId, newVersion: "2.0.0") + let phase = tracker.state(for: pluginId)?.phase + if case .stagedPendingActivation(let version) = phase { + #expect(version == "2.0.0") + } else { + Issue.record("Expected stagedPendingActivation, got \(String(describing: phase))") + } + } + + @Test("clearInstall removes staged entry") + func clearInstallRemovesStaged() { + let tracker = PluginInstallTracker.shared + tracker.markStaged(pluginId: pluginId, newVersion: "1.0.0") + tracker.clearInstall(pluginId: pluginId) + #expect(tracker.state(for: pluginId) == nil) + } +} diff --git a/TableProTests/Core/Plugins/PluginInstallerCoalescingTests.swift b/TableProTests/Core/Plugins/PluginInstallerCoalescingTests.swift new file mode 100644 index 000000000..2d2f5c87a --- /dev/null +++ b/TableProTests/Core/Plugins/PluginInstallerCoalescingTests.swift @@ -0,0 +1,32 @@ +// +// PluginInstallerCoalescingTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("PluginInstaller staged-update bookkeeping", .serialized) +struct PluginInstallerCoalescingTests { + + @Test("hasStagedUpdate returns false for unknown pluginId") + func unknownStagedUpdate() async { + let installer = PluginInstaller.shared + let unknown = await installer.hasStagedUpdate(pluginId: "com.nonexistent.plugin.\(UUID().uuidString)") + #expect(unknown == false) + } + + @Test("discardStagedUpdate is safe on unknown pluginId") + func discardUnknownIsSafe() async { + let installer = PluginInstaller.shared + await installer.discardStagedUpdate(pluginId: "com.nonexistent.plugin.\(UUID().uuidString)") + } + + @Test("stagedURL returns nil for unknown pluginId") + func unknownStagedURL() async { + let installer = PluginInstaller.shared + let url = await installer.stagedURL(for: "com.nonexistent.plugin.\(UUID().uuidString)") + #expect(url == nil) + } +} diff --git a/TableProTests/Core/Plugins/PluginInstallerHelpersTests.swift b/TableProTests/Core/Plugins/PluginInstallerHelpersTests.swift new file mode 100644 index 000000000..6d9ce34dd --- /dev/null +++ b/TableProTests/Core/Plugins/PluginInstallerHelpersTests.swift @@ -0,0 +1,143 @@ +// +// PluginInstallerHelpersTests.swift +// TableProTests +// + +import Darwin +import Foundation +import Testing +@testable import TablePro + +@Suite("PluginInstaller helpers", .serialized) +struct PluginInstallerHelpersTests { + + private func makeTempDir() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("PluginInstallerTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private func makeFakeBundle(at directory: URL, name: String) throws -> URL { + let bundle = directory.appendingPathComponent("\(name).tableplugin", isDirectory: true) + try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: true) + let contents = bundle.appendingPathComponent("Contents", isDirectory: true) + try FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true) + let plist = contents.appendingPathComponent("Info.plist") + let payload = """ + + + + + CFBundleIdentifiercom.example.\(name) + TableProPluginKitVersion13 + + + """ + try payload.write(to: plist, atomically: true, encoding: .utf8) + return bundle + } + + @Test("atomicReplace moves staged bundle into place when destination empty") + func atomicReplaceCreatesDestination() throws { + let stagingDir = try makeTempDir() + let destDir = try makeTempDir() + defer { + try? FileManager.default.removeItem(at: stagingDir) + try? FileManager.default.removeItem(at: destDir) + } + + let staged = try makeFakeBundle(at: stagingDir, name: "Driver") + let dest = destDir.appendingPathComponent("Driver.tableplugin", isDirectory: true) + + let final = try PluginInstaller.atomicReplace(stagedBundleURL: staged, destURL: dest) + #expect(FileManager.default.fileExists(atPath: final.path)) + } + + @Test("atomicReplace overwrites existing destination and removes backup") + func atomicReplaceOverwritesExisting() throws { + let stagingDir = try makeTempDir() + let destDir = try makeTempDir() + defer { + try? FileManager.default.removeItem(at: stagingDir) + try? FileManager.default.removeItem(at: destDir) + } + + let dest = try makeFakeBundle(at: destDir, name: "Driver") + let staged = try makeFakeBundle(at: stagingDir, name: "Driver") + + let final = try PluginInstaller.atomicReplace(stagedBundleURL: staged, destURL: dest) + #expect(FileManager.default.fileExists(atPath: final.path)) + + let backupURL = destDir.appendingPathComponent("Driver.tableplugin.bak") + #expect(!FileManager.default.fileExists(atPath: backupURL.path)) + } + + @Test("stripQuarantine removes the xattr without raising on missing attr") + func stripQuarantineHandlesMissingAttr() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + + let bundle = try makeFakeBundle(at: dir, name: "Driver") + PluginInstaller.stripQuarantine(at: bundle) + PluginInstaller.stripQuarantine(at: bundle) + } + + @Test("validateStagedABI rejects bundle missing TableProPluginKitVersion") + func validateStagedABIRejectsMissingKey() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + + let bundle = dir.appendingPathComponent("Bad.tableplugin", isDirectory: true) + try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: true) + let contents = bundle.appendingPathComponent("Contents", isDirectory: true) + try FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true) + let emptyPlist = """ + + + + + + """ + try emptyPlist.write(to: contents.appendingPathComponent("Info.plist"), atomically: true, encoding: .utf8) + + #expect(throws: PluginError.self) { + try PluginInstaller.validateStagedABI(bundleURL: bundle, currentKit: 13, currentInspector: 1) + } + } + + @Test("validateStagedABI passes when plist matches current kit version") + func validateStagedABIPassesMatch() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + + let bundle = try makeFakeBundle(at: dir, name: "Driver") + try PluginInstaller.validateStagedABI(bundleURL: bundle, currentKit: 13, currentInspector: 1) + } + + @Test("findBundle returns the single .tableplugin in a directory") + func findBundleReturnsSingle() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let bundle = try makeFakeBundle(at: dir, name: "Driver") + let found = try PluginInstaller.findBundle(in: dir) + #expect(found.lastPathComponent == bundle.lastPathComponent) + } + + @Test("findBundle throws when no .tableplugin found") + func findBundleThrowsWhenEmpty() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + #expect(throws: PluginError.self) { + _ = try PluginInstaller.findBundle(in: dir) + } + } + + @Test("stagingRoot is a sibling of userPluginsDir for same-volume atomic replace") + func stagingRootIsSiblingOfUserPluginsDir() { + let userPluginsDir = URL(fileURLWithPath: "/Users/test/Library/Application Support/TablePro/Plugins") + let stagingRoot = PluginInstaller.stagingRoot(for: userPluginsDir) + #expect(stagingRoot.deletingLastPathComponent() == userPluginsDir.deletingLastPathComponent()) + #expect(stagingRoot.lastPathComponent == "PluginStaging") + } +} diff --git a/TableProTests/Core/Plugins/PluginLazyLoadingTests.swift b/TableProTests/Core/Plugins/PluginLazyLoadingTests.swift index f886f55a0..8ebddaef9 100644 --- a/TableProTests/Core/Plugins/PluginLazyLoadingTests.swift +++ b/TableProTests/Core/Plugins/PluginLazyLoadingTests.swift @@ -14,34 +14,34 @@ import Testing @MainActor struct PluginLazyLoadingTests { @Test("loadPendingPlugins is idempotent when called multiple times") - func loadPendingPluginsIdempotent() { + func loadPendingPluginsIdempotent() async { // loadPendingPlugins should not crash or duplicate when called multiple times let manager = PluginManager.shared - manager.loadPendingPlugins() + await manager.loadPendingPluginsAsync() let countAfterFirst = manager.plugins.count - manager.loadPendingPlugins() + await manager.loadPendingPluginsAsync() let countAfterSecond = manager.plugins.count #expect(countAfterFirst == countAfterSecond) } @Test("loadPendingPlugins populates driverPlugins") - func loadPendingPopulatesDrivers() { + func loadPendingPopulatesDrivers() async { let manager = PluginManager.shared - manager.loadPendingPlugins() + await manager.loadPendingPluginsAsync() // After loading, at least some driver plugins should be registered // (the built-in plugins are always available in the test bundle) #expect(manager.driverPlugins.isEmpty == false || manager.plugins.isEmpty) } @Test("loadPendingPlugins with no pending is no-op") - func loadPendingNoPendingIsNoOp() { + func loadPendingNoPendingIsNoOp() async { let manager = PluginManager.shared // Ensure all pending are loaded first - manager.loadPendingPlugins() + await manager.loadPendingPluginsAsync() let driverCount = manager.driverPlugins.count let pluginCount = manager.plugins.count // Call again - should be no-op - manager.loadPendingPlugins() + await manager.loadPendingPluginsAsync() #expect(manager.driverPlugins.count == driverCount) #expect(manager.plugins.count == pluginCount) } diff --git a/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift b/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift new file mode 100644 index 000000000..f32b3de1a --- /dev/null +++ b/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift @@ -0,0 +1,85 @@ +// +// PluginManagerReconciliationTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("PluginManager reconciliation helpers", .serialized) +@MainActor +struct PluginManagerReconciliationTests { + + private func makeManifest(pluginIds: [String]) -> RegistryManifest { + let plugins = pluginIds.map { id -> RegistryPlugin in + let json = """ + { + "id": "\(id)", + "name": "Test Plugin", + "version": "1.0.0", + "summary": "test", + "author": {"name": "Tester"}, + "category": "database-driver", + "binaries": [ + {"architecture": "arm64", "downloadURL": "https://x", "sha256": "deadbeef", "pluginKitVersion": 13} + ] + } + """ + return try! JSONDecoder().decode(RegistryPlugin.self, from: Data(json.utf8)) + } + return RegistryManifest(schemaVersion: 2, plugins: plugins) + } + + private func makeRejected( + bundleId: String? = nil, + registryId: String? = nil, + isOutdated: Bool = true + ) -> RejectedPlugin { + RejectedPlugin( + url: URL(fileURLWithPath: "/tmp/test-\(UUID().uuidString).tableplugin"), + bundleId: bundleId, + registryId: registryId, + name: "Test", + reason: "ABI mismatch", + isOutdated: isOutdated + ) + } + + @Test("resolveRegistryId prefers explicit registryId from sidecar") + func resolveRegistryIdUsesRegistryId() { + let pm = PluginManager.shared + let manifest = makeManifest(pluginIds: ["com.example.driver"]) + let rejected = makeRejected(bundleId: "com.example.driver", registryId: "com.example.driver") + let resolved = pm.resolveRegistryId(for: rejected, manifest: manifest) + #expect(resolved == "com.example.driver") + } + + @Test("resolveRegistryId falls back to bundleId when sidecar missing") + func resolveRegistryIdFallsBackToBundleId() { + let pm = PluginManager.shared + let manifest = makeManifest(pluginIds: ["com.example.driver"]) + let rejected = makeRejected(bundleId: "com.example.driver", registryId: nil) + let resolved = pm.resolveRegistryId(for: rejected, manifest: manifest) + #expect(resolved == "com.example.driver") + } + + @Test("resolveRegistryId returns nil when no match in manifest") + func resolveRegistryIdReturnsNilForUnknown() { + let pm = PluginManager.shared + let manifest = makeManifest(pluginIds: ["com.example.other"]) + let rejected = makeRejected(bundleId: "com.example.driver", registryId: nil) + let resolved = pm.resolveRegistryId(for: rejected, manifest: manifest) + #expect(resolved == nil) + } + + @Test("removeFromRejected drops entries with matching URL") + func removeFromRejectedRemovesByURL() { + let pm = PluginManager.shared + let rejected = makeRejected(bundleId: "com.example.driver", registryId: "com.example.driver") + pm.rejectedPlugins.append(rejected) + let url = rejected.url + pm.removeFromRejected(url: url) + #expect(!pm.rejectedPlugins.contains { $0.url == url }) + } +} diff --git a/TableProTests/Core/Plugins/PluginManagerStagingCleanupTests.swift b/TableProTests/Core/Plugins/PluginManagerStagingCleanupTests.swift new file mode 100644 index 000000000..e6cfeffce --- /dev/null +++ b/TableProTests/Core/Plugins/PluginManagerStagingCleanupTests.swift @@ -0,0 +1,58 @@ +// +// PluginManagerStagingCleanupTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("PluginManager staging directory cleanup", .serialized) +struct PluginManagerStagingCleanupTests { + + private func makeTempPluginsDir() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("StagingTests-\(UUID().uuidString)/Plugins", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + @Test("stagingRoot derived path is a sibling of userPluginsDir") + func stagingRootSibling() throws { + let userPluginsDir = try makeTempPluginsDir() + defer { try? FileManager.default.removeItem(at: userPluginsDir.deletingLastPathComponent()) } + + let stagingRoot = PluginInstaller.stagingRoot(for: userPluginsDir) + #expect(stagingRoot.lastPathComponent == "PluginStaging") + #expect(stagingRoot.deletingLastPathComponent() == userPluginsDir.deletingLastPathComponent()) + } + + @Test("staging root can be created and populated then enumerated") + func stagingRootEnumerable() throws { + let userPluginsDir = try makeTempPluginsDir() + defer { try? FileManager.default.removeItem(at: userPluginsDir.deletingLastPathComponent()) } + + let stagingRoot = PluginInstaller.stagingRoot(for: userPluginsDir) + try FileManager.default.createDirectory(at: stagingRoot, withIntermediateDirectories: true) + + let leftover = stagingRoot.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: leftover, withIntermediateDirectories: true) + try Data("partial".utf8).write(to: leftover.appendingPathComponent("scratch.zip")) + + let contents = try FileManager.default.contentsOfDirectory( + at: stagingRoot, + includingPropertiesForKeys: nil + ) + #expect(!contents.isEmpty) + + for item in contents { + try FileManager.default.removeItem(at: item) + } + + let afterCleanup = try FileManager.default.contentsOfDirectory( + at: stagingRoot, + includingPropertiesForKeys: nil + ) + #expect(afterCleanup.isEmpty) + } +} diff --git a/TableProTests/Core/Plugins/PluginModelsTests.swift b/TableProTests/Core/Plugins/PluginModelsTests.swift index 44ffe39ba..726e859b0 100644 --- a/TableProTests/Core/Plugins/PluginModelsTests.swift +++ b/TableProTests/Core/Plugins/PluginModelsTests.swift @@ -30,7 +30,10 @@ struct PluginEntryTests { databaseTypeId: databaseTypeId, additionalTypeIds: additionalTypeIds, pluginIconName: pluginIconName, - defaultPort: defaultPort + defaultPort: defaultPort, + exportFormatId: nil, + importFormatId: nil, + inspectorId: nil ) } @@ -95,7 +98,10 @@ struct PluginEntryIdentityTests { databaseTypeId: nil, additionalTypeIds: [], pluginIconName: "puzzlepiece", - defaultPort: nil + defaultPort: nil, + exportFormatId: nil, + importFormatId: nil, + inspectorId: nil ) #expect(entry.id == "com.example.test-plugin") } diff --git a/TableProTests/Core/Plugins/RegistryBinarySelectionTests.swift b/TableProTests/Core/Plugins/RegistryBinarySelectionTests.swift new file mode 100644 index 000000000..e96f88040 --- /dev/null +++ b/TableProTests/Core/Plugins/RegistryBinarySelectionTests.swift @@ -0,0 +1,95 @@ +// +// RegistryBinarySelectionTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("RegistryPlugin.resolvedBinary v2 selection") +struct RegistryBinarySelectionTests { + + private func makePlugin(binaries: [RegistryBinary]) -> RegistryPlugin { + let payload: [String: Any] = [ + "id": "com.example.driver", + "name": "Example", + "version": "1.0.0", + "summary": "test", + "author": ["name": "Tester"], + "category": "database-driver", + "binaries": binaries.map { binary -> [String: Any] in + var dict: [String: Any] = [ + "architecture": binary.architecture.rawValue, + "downloadURL": binary.downloadURL, + "sha256": binary.sha256 + ] + if let kit = binary.pluginKitVersion { dict["pluginKitVersion"] = kit } + return dict + } + ] + let data = try! JSONSerialization.data(withJSONObject: payload) + return try! JSONDecoder().decode(RegistryPlugin.self, from: data) + } + + @Test("exact pluginKitVersion + arch match wins") + func exactMatchSelected() throws { + let plugin = makePlugin(binaries: [ + RegistryBinary(architecture: .arm64, downloadURL: "https://a/13", sha256: "aaa", pluginKitVersion: 13), + RegistryBinary(architecture: .arm64, downloadURL: "https://a/12", sha256: "bbb", pluginKitVersion: 12) + ]) + let resolved = try plugin.resolvedBinary(for: .arm64, pluginKitVersion: 13) + #expect(resolved.downloadURL == "https://a/13") + } + + @Test("nil pluginKitVersion is no longer a universal fallback") + func nilPluginKitVersionNotMatched() { + let plugin = makePlugin(binaries: [ + RegistryBinary(architecture: .arm64, downloadURL: "https://legacy", sha256: "abc", pluginKitVersion: nil) + ]) + #expect(throws: PluginError.self) { + _ = try plugin.resolvedBinary(for: .arm64, pluginKitVersion: 13) + } + } + + @Test("throws noCompatibleBinary when no arch match") + func noArchitectureMatch() { + let plugin = makePlugin(binaries: [ + RegistryBinary(architecture: .x86_64, downloadURL: "https://intel", sha256: "x", pluginKitVersion: 13) + ]) + #expect(throws: PluginError.self) { + _ = try plugin.resolvedBinary(for: .arm64, pluginKitVersion: 13) + } + } + + @Test("throws noCompatibleBinary when arch matches but no kit version match and no legacy") + func noKitVersionMatchNoLegacy() { + let plugin = makePlugin(binaries: [ + RegistryBinary(architecture: .arm64, downloadURL: "https://a/12", sha256: "bbb", pluginKitVersion: 12) + ]) + #expect(throws: PluginError.self) { + _ = try plugin.resolvedBinary(for: .arm64, pluginKitVersion: 13) + } + } + + @Test("v1 manifest with flat downloadURL/sha256 decodes to arm64 + x86_64 binaries") + func v1FlatFieldsBackwardCompat() throws { + let json = #""" + { + "id": "com.example.legacy", + "name": "Legacy", + "version": "1.0.0", + "summary": "v1 entry", + "author": {"name": "Tester"}, + "category": "database-driver", + "downloadURL": "https://legacy.example/plugin.zip", + "sha256": "deadbeef", + "minPluginKitVersion": 11 + } + """# + let plugin = try JSONDecoder().decode(RegistryPlugin.self, from: Data(json.utf8)) + #expect(plugin.binaries.count == 2) + #expect(plugin.binaries.contains { $0.architecture == .arm64 && $0.pluginKitVersion == 11 }) + #expect(plugin.binaries.contains { $0.architecture == .x86_64 && $0.pluginKitVersion == 11 }) + } +} diff --git a/scripts/build-plugin.sh b/scripts/build-plugin.sh index 0efaf3b6b..836a31d9a 100755 --- a/scripts/build-plugin.sh +++ b/scripts/build-plugin.sh @@ -16,10 +16,28 @@ PLUGIN_VERSION="${3:-${PLUGIN_VERSION:-}}" PROJECT="TablePro.xcodeproj" CONFIG="Release" BUILD_DIR="build/Plugins" -SIGN_IDENTITY="${SIGN_IDENTITY:-Developer ID Application: Dat Ngo Quoc (D7HJ5TFYCU)}" -TEAM_ID="D7HJ5TFYCU" +SIGN_IDENTITY="${SIGN_IDENTITY:-}" +TEAM_ID="${TEAM_ID:-}" NOTARIZE="${NOTARIZE:-false}" -APPLE_ID="${APPLE_ID:-datngoquoc@icloud.com}" +APPLE_ID="${APPLE_ID:-}" + +if [ -z "$TEAM_ID" ]; then + echo "ERROR: TEAM_ID is not set. Pass via env or set in your shell profile." >&2 + echo " Example: TEAM_ID=ABCDEFGHIJ ./scripts/build-plugin.sh $PLUGIN_TARGET" >&2 + exit 1 +fi + +if [ -z "$SIGN_IDENTITY" ]; then + # Try the canonical "Developer ID Application: ()" pattern. + # If your keychain stores the identity differently, set SIGN_IDENTITY explicitly. + SIGN_IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \ + | awk -F'"' -v team="$TEAM_ID" '$2 ~ /Developer ID Application/ && $2 ~ team {print $2; exit}') + if [ -z "$SIGN_IDENTITY" ]; then + echo "ERROR: No Developer ID Application identity found in keychain for team $TEAM_ID." >&2 + echo " Either install the cert or set SIGN_IDENTITY explicitly." >&2 + exit 1 + fi +fi if [ -n "$PLUGIN_VERSION" ]; then echo "Building plugin: $PLUGIN_TARGET v$PLUGIN_VERSION for $ARCH" @@ -155,6 +173,12 @@ notarize_zip() { return fi + if [ -z "$APPLE_ID" ]; then + echo "ERROR: APPLE_ID is not set but NOTARIZE=true." >&2 + echo " Pass APPLE_ID= or set notarytool-profile in your keychain." >&2 + exit 1 + fi + echo "Submitting for notarization..." if xcrun notarytool submit "$zip_path" \ --apple-id "$APPLE_ID" \ diff --git a/scripts/migrate-registry-v1-to-v2.py b/scripts/migrate-registry-v1-to-v2.py new file mode 100755 index 000000000..78eef7ba0 --- /dev/null +++ b/scripts/migrate-registry-v1-to-v2.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +""" +One-time migration: convert plugins.json from schema v1 to v2. + +Reads minPluginKitVersion from each plugin entry (or defaults to --assumed-pkv) +and copies it into each binary as pluginKitVersion. Removes the flat +downloadURL/sha256/minPluginKitVersion top-level fields. + +Usage: + python3 scripts/migrate-registry-v1-to-v2.py \ + --manifest /path/to/plugins.json \ + --assumed-pkv 13 +""" + +import argparse +import json +import os +import sys +import tempfile + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--manifest", required=True) + parser.add_argument( + "--assumed-pkv", + type=int, + required=True, + help="PluginKit version to assign to binaries that lack an explicit minPluginKitVersion", + ) + args = parser.parse_args() + + with open(args.manifest, "r", encoding="utf-8") as file: + manifest = json.load(file) + + plugins = manifest.get("plugins", []) + for plugin in plugins: + # CI historically hardcoded minPluginKitVersion: 2 regardless of the actual + # binary ABI. Always trust --assumed-pkv over the stored value. + binaries = plugin.get("binaries", []) + migrated = [] + for binary in binaries: + migrated.append({ + "architecture": binary["architecture"], + "pluginKitVersion": args.assumed_pkv, + "downloadURL": binary["downloadURL"], + "sha256": binary["sha256"], + }) + + plugin["binaries"] = migrated + + for field in ("downloadURL", "sha256", "minPluginKitVersion"): + plugin.pop(field, None) + + manifest["schemaVersion"] = 2 + + dir_path = os.path.dirname(os.path.abspath(args.manifest)) + fd, tmp = tempfile.mkstemp(dir=dir_path, suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as file: + json.dump(manifest, file, indent=2) + file.write("\n") + os.replace(tmp, args.manifest) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + + print(f"Migrated {len(plugins)} plugins to schema v2") + + +if __name__ == "__main__": + main() diff --git a/scripts/release-all-plugins.sh b/scripts/release-all-plugins.sh new file mode 100755 index 000000000..9b19a7dc8 --- /dev/null +++ b/scripts/release-all-plugins.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# Trigger a bulk re-release of all registry plugins for a given PluginKit version. +# +# Usage: ./scripts/release-all-plugins.sh +# Example: ./scripts/release-all-plugins.sh 14 +# +# Reads the latest tag for each plugin from git, pairs it with the given +# pluginKitVersion, and fires one workflow_dispatch on build-plugin.yml so all +# plugins build in parallel as a single matrix run. +# +# Prerequisites: gh CLI authenticated, run from repo root. + +set -euo pipefail + +if [ $# -lt 1 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +PKV="$1" + +# Registry-only plugins. Bundled plugins (Redis, ClickHouse, SQLite, MySQL, +# PostgreSQL, CSV/JSON/SQL/XLSX/MQL export, SQL import) ship inside the app +# bundle and must NEVER be published to the registry. +PLUGINS=( + mongodb + oracle + duckdb + mssql + cassandra + etcd + cloudflare-d1 + dynamodb + bigquery + libsql +) + +BUNDLED_PLUGINS=( + redis + clickhouse + sqlite + mysql + postgresql + csv + json + sql + xlsx + mql + sqlimport +) + +for PLUGIN in "${PLUGINS[@]}"; do + for BUNDLED in "${BUNDLED_PLUGINS[@]}"; do + if [ "$PLUGIN" = "$BUNDLED" ]; then + echo "ERROR: '$PLUGIN' is a bundled plugin and must not be published to the registry." >&2 + echo "Remove it from PLUGINS in $0." >&2 + exit 1 + fi + done +done + +TAG_LIST="" +FIRST=true +echo "Resolving latest tag for each plugin:" +for PLUGIN in "${PLUGINS[@]}"; do + LATEST_TAG=$(git tag -l "plugin-${PLUGIN}-v*" | sort -V | tail -1) + if [ -z "$LATEST_TAG" ]; then + echo " WARNING: No tag found for plugin-${PLUGIN}-v*. Skipping." + continue + fi + PAIR="${LATEST_TAG}:${PKV}" + if [ "$FIRST" = true ]; then + TAG_LIST="$PAIR" + FIRST=false + else + TAG_LIST="${TAG_LIST},${PAIR}" + fi + echo " $PAIR" +done + +if [ -z "$TAG_LIST" ]; then + echo "ERROR: No plugin tags found." >&2 + exit 1 +fi + +echo "" +echo "Dispatching build-plugin.yml with PluginKit version $PKV" +echo "" + +gh workflow run build-plugin.yml --field "tags=$TAG_LIST" + +REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) +echo "Dispatched. Monitor at: https://github.com/${REPO}/actions/workflows/build-plugin.yml"