diff --git a/DBSyn/MainView.swift b/DBSyn/MainView.swift index 974e3bf..2c1c5b7 100644 --- a/DBSyn/MainView.swift +++ b/DBSyn/MainView.swift @@ -2728,6 +2728,14 @@ struct WorkbenchAdministrationView: View { .frame(width: 175) } + Button { + exportAdministrationCSV() + } label: { + Label("Export CSV…", systemImage: "square.and.arrow.up") + } + .disabled(result.columns.isEmpty || result.rows.isEmpty) + .help("Export the currently filtered administration results as UTF-8 CSV") + Toggle("Auto-refresh", isOn: $autoRefresh) .toggleStyle(.checkbox) .help("Refresh every five seconds") @@ -2748,6 +2756,34 @@ struct WorkbenchAdministrationView: View { .padding(18) } + // Phase 60: Export the currently displayed administration data without + // requiring an external MySQL client. RFC 4180 quoting protects commas, + // double quotes and multiline field values. + @MainActor + private func exportAdministrationCSV() { + guard !result.columns.isEmpty else { return } + let panel = NSSavePanel() + panel.allowedContentTypes = [.commaSeparatedText] + panel.canCreateDirectories = true + panel.nameFieldStringValue = "DBSyn-" + page.rawValue + .replacingOccurrences(of: " ", with: "-") + ".csv" + guard panel.runModal() == .OK, let url = panel.url else { return } + func record(_ values: [String]) -> String { + values.map { value in + let escaped = value.replacingOccurrences(of: "\"", with: "\"\"") + return "\"" + escaped + "\"" + }.joined(separator: ",") + "\r\n" + } + do { + let rows = [result.columns] + filteredRows + let csv = rows.map(record).joined() + try csv.write(to: url, atomically: true, encoding: .utf8) + processActionStatus = "Exported \(filteredRows.count) rows to \(url.lastPathComponent)." + } catch { + errorMessage = "CSV export failed: \(error.localizedDescription)" + } + } + // The live charts occupy a fixed region ABOVE an independently scrolling // counter table. They cannot disappear when the user scrolls the rows. @ViewBuilder diff --git a/docs/PHASE60-ADMINISTRATION.md b/docs/PHASE60-ADMINISTRATION.md new file mode 100644 index 0000000..7bfd9a9 --- /dev/null +++ b/docs/PHASE60-ADMINISTRATION.md @@ -0,0 +1,15 @@ +# Phase 60 — Database Administration + +Builds on DBSyn Phase 59.2 without changing the Phase 50 dashboard design. + +## Administration tools + +The existing administration workspace includes Server Status, Client Connections, Users and Privileges, and Status and System Variables. These screens query the connected MySQL server directly. Session termination and account changes require appropriate MySQL privileges and confirmation. + +## Added in Phase 60 + +An **Export CSV…** action is available in the administration toolbar for populated result sets. It exports the current filtered rows and column headers as UTF-8 CSV, with fields quoted and embedded double quotes escaped. This works for Server Status, Client Connections, Users and Privileges, and system variables, as well as the other administration result screens. The export is a snapshot of the currently loaded data; it does not export chart history. + +## Validation + +Swift parser and ZIP integrity checks were performed on Linux. A macOS Xcode build and live MySQL integration test must be performed on the target Mac. diff --git a/scripts/release-dbsyn.sh b/scripts/release-dbsyn.sh index e2c4641..1ccb6e4 100755 --- a/scripts/release-dbsyn.sh +++ b/scripts/release-dbsyn.sh @@ -1,17 +1,112 @@ #!/bin/bash set -euo pipefail -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -PHASE="${1:?Usage: release-dbsyn.sh PHASE NAME}" -NAME="${2:?Usage: release-dbsyn.sh PHASE NAME}" -cd "$ROOT" -mkdir -p releases -xcodebuild -project DBSyn.xcodeproj -scheme DBSyn -configuration Release -destination 'platform=macOS' -derivedDataPath "$ROOT/build" clean build -APP="$ROOT/build/Build/Products/Release/DBSyn.app" -test -d "$APP" -SOURCE="DBSyn-Phase${PHASE}-${NAME}-Source.zip" -BINARY="DBSyn-Phase${PHASE}-${NAME}-App-Unsigned.zip" -/usr/bin/zip -qr "releases/$SOURCE" DBSyn DBSyn.xcodeproj docs scripts -x '*.DS_Store' -(cd "$ROOT/build/Build/Products/Release" && /usr/bin/zip -qr "$ROOT/releases/$BINARY" DBSyn.app) -/usr/bin/unzip -tq "releases/$SOURCE" >/dev/null -/usr/bin/unzip -tq "releases/$BINARY" >/dev/null -printf 'Created:\n %s\n %s\n' "releases/$SOURCE" "releases/$BINARY" + +# DBSyn — Developer ID direct distribution (signed and notarized app + DMG) +# Install at: /scripts/release-dbsyn-direct.sh +# Run from any directory: ./scripts/release-dbsyn-direct.sh + +APP_NAME="DBSyn" +SCHEME="DBSyn" +PROJECT="DBSyn.xcodeproj" +TEAM_ID="${TEAM_ID:-39R6U3437D}" +SIGNING_IDENTITY="${SIGNING_IDENTITY:-B99D353C88996866C49E4389B375645F76870A3F}" +NOTARY_PROFILE="${NOTARY_PROFILE:-orion-notary}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -d "$SCRIPT_DIR/$PROJECT" ]]; then + ROOT_DIR="$SCRIPT_DIR" +elif [[ -d "$SCRIPT_DIR/../$PROJECT" ]]; then + ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +else + echo "ERROR: $PROJECT not found next to this script or in its parent directory." >&2 + exit 1 +fi + +BUILD_DIR="$ROOT_DIR/build/direct-distribution" +ARCHIVE_PATH="$BUILD_DIR/$APP_NAME.xcarchive" +EXPORT_DIR="$BUILD_DIR/export" +DMG_STAGE="$BUILD_DIR/dmg-stage" +EXPORT_OPTIONS="$BUILD_DIR/ExportOptions.plist" +APP_ZIP="$BUILD_DIR/$APP_NAME-notarization.zip" +RELEASE_DIR="$ROOT_DIR/releases" +DMG_PATH="$RELEASE_DIR/$APP_NAME.dmg" + +for tool in xcodebuild xcrun codesign spctl ditto hdiutil shasum security; do + command -v "$tool" >/dev/null || { echo "ERROR: Missing $tool" >&2; exit 1; } +done +[[ -d "$ROOT_DIR/$PROJECT" ]] || { echo "ERROR: Missing $PROJECT" >&2; exit 1; } +security find-identity -v -p codesigning | grep -Fq "$SIGNING_IDENTITY" || { + echo "ERROR: Signing identity $SIGNING_IDENTITY not available in Keychain." >&2 + exit 1 +} +# Check that notarytool profile exists before spending time compiling. +xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" >/dev/null || { + echo "ERROR: Notarytool profile '$NOTARY_PROFILE' is unavailable or invalid." >&2 + exit 1 +} + +printf '\n========== DBSyn DIRECT DISTRIBUTION ==========\n' +cd "$ROOT_DIR" +rm -rf "$BUILD_DIR" +mkdir -p "$BUILD_DIR" "$EXPORT_DIR" "$DMG_STAGE" "$RELEASE_DIR" +# Keep previous releases until a new notarized DMG has been successfully built. + +cat > "$EXPORT_OPTIONS" < + + + methoddeveloper-id + signingStyleautomatic + teamID$TEAM_ID + +PLIST + +echo '==> Archiving DBSyn (Release)...' +xcodebuild -project "$PROJECT" -scheme "$SCHEME" -configuration Release \ + -destination 'generic/platform=macOS' -archivePath "$ARCHIVE_PATH" \ + DEVELOPMENT_TEAM="$TEAM_ID" CODE_SIGN_STYLE=Automatic \ + clean archive + +echo '==> Exporting Developer ID application...' +xcodebuild -exportArchive -archivePath "$ARCHIVE_PATH" \ + -exportPath "$EXPORT_DIR" -exportOptionsPlist "$EXPORT_OPTIONS" +APP_PATH="$EXPORT_DIR/$APP_NAME.app" +[[ -d "$APP_PATH" ]] || { echo "ERROR: Exported app missing: $APP_PATH" >&2; exit 1; } + +echo '==> Checking application signature and hardened runtime...' +codesign --verify --deep --strict --verbose=2 "$APP_PATH" +codesign -dv --verbose=4 "$APP_PATH" 2>&1 | grep -E 'Identifier=|Authority=|TeamIdentifier=|Runtime Version=|flags=' || true +ACTUAL_TEAM="$(codesign -dv --verbose=4 "$APP_PATH" 2>&1 | sed -n 's/^TeamIdentifier=//p' | head -1)" +[[ "$ACTUAL_TEAM" == "$TEAM_ID" ]] || { echo "ERROR: Unexpected signing team: $ACTUAL_TEAM" >&2; exit 1; } +codesign -dv --verbose=4 "$APP_PATH" 2>&1 | grep -q 'runtime' || { + echo 'ERROR: App is not signed with the hardened runtime required for notarization.' >&2 + exit 1 +} + +echo '==> Notarizing application...' +ditto -c -k --keepParent "$APP_PATH" "$APP_ZIP" +xcrun notarytool submit "$APP_ZIP" --keychain-profile "$NOTARY_PROFILE" --wait +xcrun stapler staple "$APP_PATH" +xcrun stapler validate "$APP_PATH" +spctl --assess --type execute --verbose=4 "$APP_PATH" + +echo '==> Creating installer DMG...' +ditto "$APP_PATH" "$DMG_STAGE/$APP_NAME.app" +ln -s /Applications "$DMG_STAGE/Applications" +TMP_DMG="$BUILD_DIR/$APP_NAME.dmg" +hdiutil create -volname "$APP_NAME" -srcfolder "$DMG_STAGE" -ov -format UDZO "$TMP_DMG" + +echo '==> Signing and notarizing DMG...' +codesign --force --timestamp --sign "$SIGNING_IDENTITY" "$TMP_DMG" +codesign --verify --verbose=2 "$TMP_DMG" +xcrun notarytool submit "$TMP_DMG" --keychain-profile "$NOTARY_PROFILE" --wait +xcrun stapler staple "$TMP_DMG" +xcrun stapler validate "$TMP_DMG" +spctl --assess --type open --context context:primary-signature --verbose=4 "$TMP_DMG" + +# Publish only after all checks have passed. +cp -f "$TMP_DMG" "$DMG_PATH" +echo '==> Release ready:' +ls -lh "$DMG_PATH" +shasum -a 256 "$DMG_PATH" +printf '\n========== DBSyn RELEASE COMPLETE ==========\n%s\n' "$DMG_PATH" diff --git a/scripts/release-workbench.sh b/scripts/release-workbench.sh deleted file mode 100755 index 516a2de..0000000 --- a/scripts/release-workbench.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash -set -euo pipefail -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -PHASE="${1:?Usage: release-workbench.sh PHASE NAME}" -NAME="${2:?Usage: release-workbench.sh PHASE NAME}" -cd "$ROOT" -mkdir -p releases -xcodebuild -project WorkbenchReplacement.xcodeproj -scheme WorkbenchReplacement -configuration Release -destination 'platform=macOS' -derivedDataPath "$ROOT/build" clean build -APP="$ROOT/build/Build/Products/Release/WorkbenchReplacement.app" -test -d "$APP" -SOURCE="WorkbenchReplacement-Phase${PHASE}-${NAME}-Source.zip" -BINARY="WorkbenchReplacement-Phase${PHASE}-${NAME}-App-Unsigned.zip" -/usr/bin/zip -qr "releases/$SOURCE" WorkbenchReplacement WorkbenchReplacement.xcodeproj docs scripts -x '*.DS_Store' -(cd "$ROOT/build/Build/Products/Release" && /usr/bin/zip -qr "$ROOT/releases/$BINARY" WorkbenchReplacement.app) -/usr/bin/unzip -tq "releases/$SOURCE" >/dev/null -/usr/bin/unzip -tq "releases/$BINARY" >/dev/null -printf 'Created:\n %s\n %s\n' "releases/$SOURCE" "releases/$BINARY"