From 02ae72beee10a5b696d52f1298c1eb9364df2a1a Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Thu, 24 Sep 2026 00:24:45 -0400 Subject: [PATCH 1/2] Ship one update zip per installed bundle folder name Squirrel.Mac renames an install to the folder name inside the update zip whenever the installed app's executable and folder names match. With the zip now carrying Whiteboard.app, every Review.app turned into Whiteboard.app on its next update, the Dock tile became a question mark, and the post-install relaunch failed with -67068 because ShipIt re-verified the old path. Package the same signed, stapled app under each folder name still in the field (Review.app and Whiteboard.app for stable, Whiteboard Preview and Review Preview for preview) and zip each copy, so the rename is a no-op for every install. latest.json lists the payloads under bundles, keyed by folder name, with the channel's default first; the client sends its own folder name as ?bundle= and the update Worker answers with the matching zip. Stable defaults to Review for clients that predate the parameter; preview defaults to Whiteboard Preview, where most installs have already crossed the rename. release-channel.mjs owns the list, notarize-macos.sh produces the zips, validate-release-artifacts.mjs checks each zip's top-level folder and emits the manifest, and the workflows upload every zip and verify both answers from the feed. Agent-Session: 78ffd160-974b-41b4-b7dd-d536de7c2833 Agent-Session: f1eb7ebd-a71b-44e2-8563-daff06cc220e Agent-Session: aad32659-bbbe-45b2-b163-317afc1a6a83 --- .github/workflows/review-desktop-preview.yml | 12 +++- .github/workflows/review-desktop-release.yml | 12 +++- apps/review-desktop/README.md | 11 ++-- .../electron-main/abstractUpdateService.ts | 6 ++ .../electron-main/updateService.darwin.ts | 7 ++- apps/review-desktop/scripts/notarize-macos.sh | 25 +++++--- .../scripts/release-channel.mjs | 34 +++++++++++ .../scripts/validate-release-artifacts.mjs | 58 +++++++++++++++---- .../validate-release-artifacts.test.mjs | 16 ++++- 9 files changed, 149 insertions(+), 32 deletions(-) diff --git a/.github/workflows/review-desktop-preview.yml b/.github/workflows/review-desktop-preview.yml index 748ac5a85..9fcd7e680 100644 --- a/.github/workflows/review-desktop-preview.yml +++ b/.github/workflows/review-desktop-preview.yml @@ -407,9 +407,10 @@ jobs: RELEASE_PREFIX="s3://${R2_BUCKET}/releases/${RELEASE_VERSION}/darwin-arm64" DMG_DISPOSITION="attachment; filename=\"df-whiteboard-preview-${RELEASE_VERSION}.dmg\"" - aws s3 cp "$DIST/Whiteboard-darwin-arm64-${RELEASE_VERSION}.zip" \ - "${RELEASE_PREFIX}/Whiteboard-darwin-arm64-${RELEASE_VERSION}.zip" \ - --content-type application/zip + for zip in "$DIST"/*-darwin-arm64-"${RELEASE_VERSION}".zip; do + aws s3 cp "$zip" "${RELEASE_PREFIX}/$(basename "$zip")" \ + --content-type application/zip + done aws s3 cp "$DIST/Whiteboard-darwin-arm64-${RELEASE_VERSION}.dmg" \ "${RELEASE_PREFIX}/Whiteboard-darwin-arm64-${RELEASE_VERSION}.dmg" \ --content-type application/x-apple-diskimage \ @@ -431,6 +432,11 @@ jobs: echo "$RESPONSE" echo "$RESPONSE" | grep -F "\"productVersion\":\"${RELEASE_VERSION}\"" echo "$RESPONSE" | grep -F "\"version\":\"${RELEASE_COMMIT}\"" + echo "$RESPONSE" | grep -F "/Whiteboard-darwin-arm64-${RELEASE_VERSION}.zip" + + echo "Feed for a Review Preview.app install (expect the Review zip):" + curl -sf "https://update.dev.fast/api/update/darwin-arm64/preview/0000000000000000000000000000000000000000?bundle=Review%20Preview" \ + | grep -F "/Review-darwin-arm64-${RELEASE_VERSION}.zip" echo "Feed for the just-released commit (expect 204):" STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://update.dev.fast/api/update/darwin-arm64/preview/${RELEASE_COMMIT}") diff --git a/.github/workflows/review-desktop-release.yml b/.github/workflows/review-desktop-release.yml index 03458d2a8..81757f2c0 100644 --- a/.github/workflows/review-desktop-release.yml +++ b/.github/workflows/review-desktop-release.yml @@ -582,9 +582,10 @@ jobs: # rides along in the filename a client saves the download as. DMG_DISPOSITION="attachment; filename=\"df-whiteboard-${RELEASE_VERSION}.dmg\"" - aws s3 cp "$DIST/Whiteboard-darwin-arm64-${RELEASE_VERSION}.zip" \ - "${RELEASE_PREFIX}/Whiteboard-darwin-arm64-${RELEASE_VERSION}.zip" \ - --content-type application/zip + for zip in "$DIST"/*-darwin-arm64-"${RELEASE_VERSION}".zip; do + aws s3 cp "$zip" "${RELEASE_PREFIX}/$(basename "$zip")" \ + --content-type application/zip + done aws s3 cp "$DIST/Whiteboard-darwin-arm64-${RELEASE_VERSION}.dmg" \ "${RELEASE_PREFIX}/Whiteboard-darwin-arm64-${RELEASE_VERSION}.dmg" \ --content-type application/x-apple-diskimage \ @@ -606,6 +607,11 @@ jobs: echo "$RESPONSE" echo "$RESPONSE" | grep -F "\"productVersion\":\"${RELEASE_VERSION}\"" echo "$RESPONSE" | grep -F "\"version\":\"${RELEASE_COMMIT}\"" + echo "$RESPONSE" | grep -F "/Review-darwin-arm64-${RELEASE_VERSION}.zip" + + echo "Feed for a Whiteboard.app install (expect the Whiteboard zip):" + curl -sf "https://update.dev.fast/api/update/darwin-arm64/stable/0000000000000000000000000000000000000000?bundle=Whiteboard" \ + | grep -F "/Whiteboard-darwin-arm64-${RELEASE_VERSION}.zip" echo "Feed for the just-released commit (expect 204):" STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://update.dev.fast/api/update/darwin-arm64/stable/${RELEASE_COMMIT}") diff --git a/apps/review-desktop/README.md b/apps/review-desktop/README.md index 6cb369d35..058231458 100644 --- a/apps/review-desktop/README.md +++ b/apps/review-desktop/README.md @@ -115,8 +115,9 @@ SKIP_NOTARIZE=1 pnpm --filter @dev.fast/review-desktop app:package:macos builds an unsigned `VSCode-darwin-arm64/Whiteboard.app` and skips signing, notarization, and artifact creation. A full run needs the signing -environment and produces `dist/Whiteboard-darwin-arm64-.zip` (the -Squirrel update payload) and the matching `.dmg`, both notarized and stapled: +environment and produces the `.dmg` plus one Squirrel update zip per installed +bundle folder name (`Whiteboard-…zip`, `Review-…zip`; see `release-channel.mjs`), +all notarized and stapled: - `CODESIGN_IDENTITY` — the Developer ID Application identity string. - Keychain: `CODESIGN_KEYCHAIN` (explicit path), or `AGENT_TEMPDIRECTORY` @@ -219,8 +220,9 @@ Preview uses its own bundle identifier, URL scheme, CLI name, and data folders, so it can run beside stable without replacing the stable app or sharing its settings. Preview updates continue to use the preview feed. To return to stable, open the existing `Whiteboard.app` or install it from . -Auto-updated Review installs keep the `Review.app` file name; reinstall from -the disk image to get `Whiteboard.app`. +Squirrel renames an install to the update zip's folder name, so the client +sends its own folder name (`?bundle=`) and the feed answers with the matching +zip: `Review.app` installs stay `Review.app`, fresh installs are `Whiteboard.app`. Builds from before the preview identity split installed as `Review.app`. Reinstall once from after the split so the @@ -285,6 +287,7 @@ browser download always does. update/stable/darwin-arm64/latest.json current-release manifest update/preview/darwin-arm64/latest.json current-preview manifest releases//darwin-arm64/ Whiteboard-darwin-arm64-.zip + .dmg + Review-darwin-arm64-.zip (Review.app-named copy) releases/latest/darwin-arm64/Whiteboard.dmg direct-download alias, saved as df-whiteboard-.dmg diff --git a/apps/review-desktop/code-oss/src/vs/platform/update/electron-main/abstractUpdateService.ts b/apps/review-desktop/code-oss/src/vs/platform/update/electron-main/abstractUpdateService.ts index 09971caf6..abc687357 100644 --- a/apps/review-desktop/code-oss/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/apps/review-desktop/code-oss/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -28,6 +28,8 @@ const LAST_KNOWN_VERSION_STORAGE_KEY = 'abstractUpdateService/lastKnownVersion'; export interface IUpdateURLOptions { readonly background?: boolean; readonly internalOrg?: string; + /** The .app folder name this install runs from (without .app). The feed serves the zip whose folder matches, so Squirrel never renames the install. */ + readonly bundle?: string; } export function createUpdateURL(baseUpdateUrl: string, platform: string, quality: string, commit: string, options?: IUpdateURLOptions): string { @@ -39,6 +41,10 @@ export function createUpdateURL(baseUpdateUrl: string, platform: string, quality url.searchParams.set('u', options?.internalOrg ?? 'none'); + if (options?.bundle) { + url.searchParams.set('bundle', options.bundle); + } + return url.toString(); } diff --git a/apps/review-desktop/code-oss/src/vs/platform/update/electron-main/updateService.darwin.ts b/apps/review-desktop/code-oss/src/vs/platform/update/electron-main/updateService.darwin.ts index 193f937fe..ae25548d9 100644 --- a/apps/review-desktop/code-oss/src/vs/platform/update/electron-main/updateService.darwin.ts +++ b/apps/review-desktop/code-oss/src/vs/platform/update/electron-main/updateService.darwin.ts @@ -35,6 +35,11 @@ import { AvailableForDownload, IUpdate, State, StateType, UpdateType } from '../ import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; import { AbstractUpdateService, createUpdateURL, getUpdateRequestHeaders, IUpdateURLOptions, UpdateErrorClassification } from './abstractUpdateService.js'; +/** The .app folder name this process runs from, e.g. "Review" for /Applications/Review.app; undefined outside a bundle. */ +function darwinBundleName(): string | undefined { + return /\/([^/]+)\.app\/Contents\/MacOS\//.exec(process.execPath)?.[1]; +} + export class DarwinUpdateService extends AbstractUpdateService implements IRelaunchHandler { private feedUrlError: string | undefined; @@ -114,7 +119,7 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau protected buildUpdateFeedUrl(quality: string, commit: string, options?: IUpdateURLOptions): string | undefined { this.feedUrlError = undefined; const assetID = this.productService.darwinUniversalAssetId ?? (process.arch === 'x64' ? 'darwin' : 'darwin-arm64'); - const url = createUpdateURL(this.productService.updateUrl!, assetID, quality, commit, options); + const url = createUpdateURL(this.productService.updateUrl!, assetID, quality, commit, { ...options, bundle: darwinBundleName() }); const headers = getUpdateRequestHeaders(this.productService.version); try { this.logService.trace('update#buildUpdateFeedUrl - setting feed URL for Electron autoUpdater', { url, assetID, quality, commit, headers }); diff --git a/apps/review-desktop/scripts/notarize-macos.sh b/apps/review-desktop/scripts/notarize-macos.sh index 059297ce5..433af80cf 100755 --- a/apps/review-desktop/scripts/notarize-macos.sh +++ b/apps/review-desktop/scripts/notarize-macos.sh @@ -8,7 +8,7 @@ PRODUCT_NAME="$(node -p "require('$CHECKOUT/product.json').nameShort")" PACKAGED_APP="$APP_DIR/VSCode-darwin-arm64/$PRODUCT_NAME.app" VERSION="$(node -p "require('$APP_DIR/package.json').version")" ARTIFACT_DIR="${DEV_FAST_REVIEW_ARTIFACT_DIR:-$APP_DIR/dist}" -UPDATE_ZIP="$ARTIFACT_DIR/Whiteboard-darwin-arm64-$VERSION.zip" +QUALITY="$(node -p "require('$CHECKOUT/product.json').quality")" DMG="$ARTIFACT_DIR/Whiteboard-darwin-arm64-$VERSION.dmg" if (( $# > 0 )); then @@ -137,7 +137,7 @@ codesign --verify --deep --strict --verbose=2 "$PACKAGED_APP" codesign -dv --verbose=2 "$PACKAGED_APP" mkdir -p "$ARTIFACT_DIR" -rm -f -- "$UPDATE_ZIP" "$DMG" +rm -f -- "$ARTIFACT_DIR"/*-darwin-arm64-"$VERSION".zip "$DMG" mkdir -p "$DMG_STAGE" ditto "$PACKAGED_APP" "$DMG_STAGE/$PRODUCT_NAME.app" @@ -167,9 +167,18 @@ xcrun stapler validate "$PACKAGED_APP" spctl -a -vv --type exec "$PACKAGED_APP" spctl -a -vv --type open --context context:primary-signature "$DMG" -# The update zip ships the stapled app. -ditto -c -k --keepParent "$PACKAGED_APP" "$UPDATE_ZIP" - -echo "Created notarized Review Desktop artifacts:" -echo " $UPDATE_ZIP" -echo " $DMG" +# One update zip per bundle folder name still installed (release-channel.mjs +# lists them): each ships the same stapled app under that folder name, so +# Squirrel's rename-to-the-update's-name is a no-op for every install. +UPDATE_ZIPS=() +while IFS=$'\t' read -r bundle artifact; do + staged="$TEMP_ROOT/zips/$artifact/$bundle.app" + mkdir -p "$(dirname "$staged")" + ditto "$PACKAGED_APP" "$staged" + zip="$ARTIFACT_DIR/$artifact-darwin-arm64-$VERSION.zip" + ditto -c -k --keepParent "$staged" "$zip" + UPDATE_ZIPS+=("$zip") +done < <(node "$APP_DIR/scripts/release-channel.mjs" "$QUALITY") + +echo "Created notarized Whiteboard Desktop artifacts:" +printf ' %s\n' "${UPDATE_ZIPS[@]}" "$DMG" diff --git a/apps/review-desktop/scripts/release-channel.mjs b/apps/review-desktop/scripts/release-channel.mjs index 10512d2d8..4e1bcc4b7 100644 --- a/apps/review-desktop/scripts/release-channel.mjs +++ b/apps/review-desktop/scripts/release-channel.mjs @@ -19,6 +19,22 @@ const RELEASE_IDENTITIES = Object.freeze({ }), }); +// Squirrel renames an install to the folder name inside the update zip, so a +// release ships one zip per folder name still installed: `bundle` is that +// folder name (minus .app), `artifact` prefixes the zip file. The first entry +// is what a client that does not name its folder receives; the DMG always +// carries the channel's nameShort. +const UPDATE_BUNDLES = Object.freeze({ + stable: Object.freeze([ + Object.freeze({ bundle: "Review", artifact: "Review" }), + Object.freeze({ bundle: "Whiteboard", artifact: "Whiteboard" }), + ]), + preview: Object.freeze([ + Object.freeze({ bundle: "Whiteboard Preview", artifact: "Whiteboard" }), + Object.freeze({ bundle: "Review Preview", artifact: "Review" }), + ]), +}); + export function assertReleaseChannel(channel) { if (!Object.hasOwn(RELEASE_IDENTITIES, channel)) { throw new Error( @@ -32,3 +48,21 @@ export function releaseIdentityFor(channel) { return RELEASE_IDENTITIES[channel]; } + +export function updateBundlesFor(channel) { + assertReleaseChannel(channel); + + return UPDATE_BUNDLES[channel]; +} + +export function updateZipName(artifact, version) { + return `${artifact}-darwin-arm64-${version}.zip`; +} + +if (process.argv[1] === new URL(import.meta.url).pathname) { + // `node release-channel.mjs ` prints one "bundleartifact" + // line per update zip, for the packaging shell scripts. + for (const { bundle, artifact } of updateBundlesFor(process.argv[2])) { + console.log(`${bundle}\t${artifact}`); + } +} diff --git a/apps/review-desktop/scripts/validate-release-artifacts.mjs b/apps/review-desktop/scripts/validate-release-artifacts.mjs index 52b7e0552..f86c8bebb 100644 --- a/apps/review-desktop/scripts/validate-release-artifacts.mjs +++ b/apps/review-desktop/scripts/validate-release-artifacts.mjs @@ -17,6 +17,8 @@ import { verifyCuratedExtensions } from "./curated-extensions.mjs"; import { assertReleaseChannel, releaseIdentityFor, + updateBundlesFor, + updateZipName, } from "./release-channel.mjs"; import { assertPackagedArtifacts } from "./stage-review-runtime.mjs"; @@ -26,23 +28,46 @@ const UPDATE_URL = "https://update.dev.fast"; export { assertReleaseChannel }; -export function buildManifest({ - version, - commit, - zipSha256, - now = new Date(), -}) { +// `payloads` is one entry per update zip, in updateBundlesFor() order: the +// first is the default for clients that do not name their bundle folder. +export function buildManifest({ version, commit, payloads, now = new Date() }) { + const bundles = Object.fromEntries( + payloads.map(({ bundle, artifact, sha256 }) => [ + bundle, + { + url: `${UPDATE_URL}/releases/${version}/darwin-arm64/${updateZipName(artifact, version)}`, + sha256hash: sha256, + }, + ]), + ); + const [fallback] = Object.values(bundles); + return { version, commit, - url: `${UPDATE_URL}/releases/${version}/darwin-arm64/Whiteboard-darwin-arm64-${version}.zip`, + ...fallback, name: version, pub_date: now.toISOString(), timestamp: now.getTime(), - sha256hash: zipSha256, + bundles, }; } +// Squirrel installs the zip's top-level folder under that name, so a zip whose +// folder does not match the bundle it is served to would rename the install. +export function assertZipFolder(zip, folder) { + const entries = execFileSync("unzip", ["-Z1", zip], { encoding: "utf8" }) + .split("\n") + .filter(Boolean); + const roots = new Set(entries.map((entry) => entry.split("/")[0])); + + if (roots.size !== 1 || !roots.has(folder)) { + throw new Error( + `${zip} must contain only ${folder}/, found ${[...roots].join(", ") || "nothing"}`, + ); + } +} + export function assertPackagedProduct(product, { commit, channel = "stable" }) { assertReleaseChannel(channel); @@ -136,7 +161,11 @@ async function main() { `${sourceProduct.nameShort}.app`, ); - const zip = path.join(artifactDir, `Whiteboard-darwin-arm64-${version}.zip`); + const zips = updateBundlesFor(channel).map(({ bundle, artifact }) => ({ + bundle, + artifact, + file: path.join(artifactDir, updateZipName(artifact, version)), + })); const dmg = path.join(artifactDir, `Whiteboard-darwin-arm64-${version}.dmg`); await assertPackagedArtifacts(app); @@ -159,12 +188,19 @@ async function main() { run("spctl", ["-a", "-vv", "--type", "exec", app]); run("xcrun", ["stapler", "validate", dmg]); - const manifest = buildManifest({ version, commit, zipSha256: sha256(zip) }); + for (const { bundle, file } of zips) { + assertZipFolder(file, `${bundle}.app`); + } + + const payloads = zips.map((zip) => ({ ...zip, sha256: sha256(zip.file) })); + const manifest = buildManifest({ version, commit, payloads }); const manifestPath = path.join(artifactDir, "latest.json"); writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); console.log(`Validated release artifacts for ${version} (${commit}):`); - console.log(` ${zip} sha256=${manifest.sha256hash}`); + for (const { bundle, file, sha256 } of payloads) { + console.log(` ${file} (${bundle}.app) sha256=${sha256}`); + } console.log(` ${dmg} sha256=${sha256(dmg)}`); console.log(` ${manifestPath}`); } diff --git a/apps/review-desktop/scripts/validate-release-artifacts.test.mjs b/apps/review-desktop/scripts/validate-release-artifacts.test.mjs index 670bffc53..807475d73 100644 --- a/apps/review-desktop/scripts/validate-release-artifacts.test.mjs +++ b/apps/review-desktop/scripts/validate-release-artifacts.test.mjs @@ -33,18 +33,30 @@ test("buildManifest emits the schema the update Worker serves", () => { const manifest = buildManifest({ version: "1.2.3", commit: "abc123", - zipSha256: "cafe", + payloads: [ + { bundle: "Review", artifact: "Review", sha256: "cafe" }, + { bundle: "Whiteboard", artifact: "Whiteboard", sha256: "f00d" }, + ], now, }); + const review = + "https://update.dev.fast/releases/1.2.3/darwin-arm64/Review-darwin-arm64-1.2.3.zip"; assert.deepEqual(manifest, { version: "1.2.3", commit: "abc123", - url: "https://update.dev.fast/releases/1.2.3/darwin-arm64/Whiteboard-darwin-arm64-1.2.3.zip", + url: review, name: "1.2.3", pub_date: "2026-07-29T00:00:00.000Z", timestamp: now.getTime(), sha256hash: "cafe", + bundles: { + Review: { url: review, sha256hash: "cafe" }, + Whiteboard: { + url: "https://update.dev.fast/releases/1.2.3/darwin-arm64/Whiteboard-darwin-arm64-1.2.3.zip", + sha256hash: "f00d", + }, + }, }); }); From 770268534c39a0468145541331189b54b287d3db Mon Sep 17 00:00:00 2001 From: thesiti92 Date: Thu, 24 Sep 2026 00:26:32 -0400 Subject: [PATCH 2/2] Add the blank lines require-readable-spacing wants Agent-Session: 78ffd160-974b-41b4-b7dd-d536de7c2833 Agent-Session: f1eb7ebd-a71b-44e2-8563-daff06cc220e Agent-Session: aad32659-bbbe-45b2-b163-317afc1a6a83 --- apps/review-desktop/scripts/validate-release-artifacts.mjs | 5 +++++ .../scripts/validate-release-artifacts.test.mjs | 1 + 2 files changed, 6 insertions(+) diff --git a/apps/review-desktop/scripts/validate-release-artifacts.mjs b/apps/review-desktop/scripts/validate-release-artifacts.mjs index f86c8bebb..c8bd8c82a 100644 --- a/apps/review-desktop/scripts/validate-release-artifacts.mjs +++ b/apps/review-desktop/scripts/validate-release-artifacts.mjs @@ -40,6 +40,7 @@ export function buildManifest({ version, commit, payloads, now = new Date() }) { }, ]), ); + const [fallback] = Object.values(bundles); return { @@ -59,6 +60,7 @@ export function assertZipFolder(zip, folder) { const entries = execFileSync("unzip", ["-Z1", zip], { encoding: "utf8" }) .split("\n") .filter(Boolean); + const roots = new Set(entries.map((entry) => entry.split("/")[0])); if (roots.size !== 1 || !roots.has(folder)) { @@ -166,6 +168,7 @@ async function main() { artifact, file: path.join(artifactDir, updateZipName(artifact, version)), })); + const dmg = path.join(artifactDir, `Whiteboard-darwin-arm64-${version}.dmg`); await assertPackagedArtifacts(app); @@ -198,9 +201,11 @@ async function main() { writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); console.log(`Validated release artifacts for ${version} (${commit}):`); + for (const { bundle, file, sha256 } of payloads) { console.log(` ${file} (${bundle}.app) sha256=${sha256}`); } + console.log(` ${dmg} sha256=${sha256(dmg)}`); console.log(` ${manifestPath}`); } diff --git a/apps/review-desktop/scripts/validate-release-artifacts.test.mjs b/apps/review-desktop/scripts/validate-release-artifacts.test.mjs index 807475d73..78e7acc28 100644 --- a/apps/review-desktop/scripts/validate-release-artifacts.test.mjs +++ b/apps/review-desktop/scripts/validate-release-artifacts.test.mjs @@ -42,6 +42,7 @@ test("buildManifest emits the schema the update Worker serves", () => { const review = "https://update.dev.fast/releases/1.2.3/darwin-arm64/Review-darwin-arm64-1.2.3.zip"; + assert.deepEqual(manifest, { version: "1.2.3", commit: "abc123",