Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 10 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -732,17 +732,21 @@ jobs:
# release-only simstudioai/sim-desktop-releases repository. Keeping these
# builds out of this source repository prevents its followers from receiving
# every internal shell release. Each environment's /api/desktop/update feed
# still offers only its own stream. Unlike stable releases, prereleases build
# before the Apple signing secrets exist — unsigned, so the update pipeline
# remains testable end to end with a manual download.
# still offers only its own signed stream.
create-desktop-prerelease:
name: Create Desktop Prerelease
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 5
needs: [detect-desktop-changes, check-desktop-signing]
# Requires the signing probe to have actually succeeded (not just "not
# cancelled") so a probe failure can't produce a release with no build.
if: ${{ !cancelled() && needs.detect-desktop-changes.outputs.changed == 'true' && needs.check-desktop-signing.result == 'success' }}
if: >-
${{
!cancelled() &&
needs.detect-desktop-changes.outputs.changed == 'true' &&
needs.check-desktop-signing.result == 'success' &&
needs.check-desktop-signing.outputs.configured == 'true'
}}
permissions:
contents: read
outputs:
Expand All @@ -758,13 +762,12 @@ jobs:
GH_TOKEN: ${{ github.token }}
PRERELEASE_REPOSITORY: simstudioai/sim-desktop-releases
SOURCE_REPOSITORY: ${{ github.repository }}
SIGNED: ${{ needs.check-desktop-signing.outputs.configured }}
run: |
if [ -z "$DESKTOP_RELEASE_TOKEN" ]; then
echo "::error::DESKTOP_RELEASE_TOKEN is required to publish desktop prereleases."
exit 1
fi
if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=dev; APP_NAME="Sim Dev"; else CHANNEL=staging; APP_NAME="Sim Staging"; fi
if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=dev; else CHANNEL=staging; fi
# Prerelease core = next patch after the latest stable release, so
# channel builds always outrank the stable they are built on top of
# and are always superseded by the next stable. The run-attempt
Expand All @@ -786,11 +789,6 @@ jobs:
IFS='.' read -r MAJOR MINOR PATCH <<< "${LATEST#v}"
TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))-${CHANNEL}.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}"
NOTES="Automated ${CHANNEL}-channel desktop build from ${GITHUB_REF_NAME} @ ${GITHUB_SHA::7}."
if [ "$SIGNED" != "true" ]; then
NOTES="$NOTES

⚠️ Unsigned test build (Apple signing secrets not configured). Gatekeeper will quarantine a downloaded copy: right-click → Open, or clear the flag with \`xattr -dr com.apple.quarantine \"/Applications/${APP_NAME}.app\"\`."
fi
# Draft until the build uploads its artifacts: drafts are invisible
# to the update feed, so a failed or in-flight build can never take
# the channel down with an assetless release. The release-only repo
Expand Down Expand Up @@ -818,7 +816,7 @@ jobs:
with:
version: ${{ needs.create-desktop-prerelease.outputs.version }}
publish: true
sign: ${{ needs.check-desktop-signing.outputs.configured == 'true' }}
sign: true
secrets: inherit

# The draft only becomes visible to the update feed once its artifacts are
Expand Down
42 changes: 32 additions & 10 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,8 @@ on:
type: boolean
default: true
sign:
description: Sign and notarize with the Apple Developer identity. When
false (prerelease testing before the signing secrets exist) the build
is packaged unsigned; installed shells detect this and offer manual
downloads instead of Squirrel installs.
description: Sign and notarize with the Apple Developer identity. Unsigned
builds are workflow artifacts only and cannot be published.
required: false
type: boolean
default: true
Expand Down Expand Up @@ -50,6 +48,7 @@ jobs:
build-sign-notarize:
name: Build, Sign, Notarize
runs-on: macos-26
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
Expand Down Expand Up @@ -101,8 +100,8 @@ jobs:
echo "::error::Manual desktop releases must use a stable source-repository tag."
exit 1
fi
if [ "$TOKEN_KIND" = stable ] && [ "$PUBLISH" = true ] && [ "$SIGN" != true ]; then
echo "::error::Stable desktop releases must be signed before publication."
if [ "$PUBLISH" = true ] && [ "$SIGN" != true ]; then
echo "::error::Desktop releases must be signed before publication."
exit 1
fi
if [ "$TOKEN_KIND" = stable ]; then
Expand Down Expand Up @@ -206,10 +205,9 @@ jobs:
bunx electron-builder --mac --publish never
-c.productName="$PRODUCT_NAME" -c.appId="$APP_ID"

# Unsigned prerelease path: no Developer ID, no notarization. The
# binaries are explicitly ad-hoc signed with Hardened Runtime off, which
# runs locally but gets quarantined when downloaded — fine for testing
# the update pipeline without Developer ID credentials.
# Unsigned artifact-only path: no Developer ID or notarization. The bundle
# is ad-hoc signed with Hardened Runtime off for local workflow testing and
# must never be published.
- name: Package unsigned
if: ${{ !inputs.sign }}
working-directory: apps/desktop
Expand Down Expand Up @@ -274,13 +272,35 @@ jobs:
- name: Validate signature and notarization
if: ${{ inputs.sign }}
env:
APP_ID: ${{ steps.channel.outputs.app_id }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
PRODUCT_NAME: ${{ steps.channel.outputs.name }}
VERSION: ${{ inputs.version }}
run: |
SEMVER="${VERSION#v}"
DMG="apps/desktop/release/Sim-${SEMVER}-universal.dmg"
ZIP="apps/desktop/release/Sim-${SEMVER}-universal.zip"
MOUNT_POINT="$RUNNER_TEMP/sim-dmg"
ZIP_DIR="$(mktemp -d "$RUNNER_TEMP/sim-zip.XXXXXX")"
validate_identity() {
local APP_BUNDLE="$1"
local ACTUAL_APP_ID ACTUAL_NAME SIGNATURE
ACTUAL_APP_ID="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP_BUNDLE/Contents/Info.plist")"
ACTUAL_NAME="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleName' "$APP_BUNDLE/Contents/Info.plist")"
SIGNATURE="$(codesign -dv --verbose=4 "$APP_BUNDLE" 2>&1)"
if [ "$ACTUAL_APP_ID" != "$APP_ID" ] || [ "$ACTUAL_NAME" != "$PRODUCT_NAME" ]; then
echo "::error::Unexpected packaged identity: $ACTUAL_NAME ($ACTUAL_APP_ID)."
exit 1
fi
if ! grep -Fxq "TeamIdentifier=$APPLE_TEAM_ID" <<< "$SIGNATURE"; then
echo "::error::The app was not signed by the expected Apple team."
exit 1
fi
if ! grep -Eq 'flags=.*runtime' <<< "$SIGNATURE"; then
echo "::error::The app was not signed with Hardened Runtime."
exit 1
fi
}
mkdir -p "$MOUNT_POINT"
hdiutil attach "$DMG" -mountpoint "$MOUNT_POINT" -nobrowse -quiet
trap 'hdiutil detach "$MOUNT_POINT" -quiet || true; rm -rf "$ZIP_DIR"' EXIT
Expand All @@ -292,6 +312,7 @@ jobs:
xcrun stapler validate "$APP_BUNDLE"
spctl --assess --type execute --verbose "$APP_BUNDLE"
codesign --verify --deep --strict "$APP_BUNDLE"
validate_identity "$APP_BUNDLE"
unzip -q "$ZIP" -d "$ZIP_DIR"
ZIP_APP="$(find "$ZIP_DIR" -maxdepth 2 -name '*.app' -print -quit)"
if [ -z "$ZIP_APP" ]; then
Expand All @@ -301,6 +322,7 @@ jobs:
xcrun stapler validate "$ZIP_APP"
spctl --assess --type execute --verbose "$ZIP_APP"
codesign --verify --deep --strict "$ZIP_APP"
validate_identity "$ZIP_APP"
hdiutil detach "$MOUNT_POINT" -quiet
rm -rf "$ZIP_DIR"
trap - EXIT
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ Raw local file bytes are never exposed through the preload bridge and cannot be

## Auto-update, channels, rollout, rollback

- `electron-updater` reads the deployment's `/api/desktop/update` feed; production resolves stable releases from `simstudioai/sim`, while dev/staging resolve prereleases from `simstudioai/sim-desktop-releases`. Artifact downloads go directly to GitHub and deltas use `.zip.blockmap`. Install is prompt-based (Restart Now / Later; Later installs on quit) — never forced mid-session.
- `electron-updater` reads the deployment's `/api/desktop/update` feed; production resolves stable releases from `simstudioai/sim`, while dev/staging resolve prereleases from `simstudioai/sim-desktop-releases`. Artifact downloads go directly to GitHub and deltas use `.zip.blockmap`. Sim validates every candidate before starting its download. Developer ID builds installed under `/Applications` use a prompt (Restart and update / Later; Later installs on quit); other packaged builds offer a validated installer download — never forced mid-session.
- Streams: production follows stable `X.Y.Z` releases, dev follows `-dev.N`, and staging follows `-staging.N`. The feed still recognizes legacy `-alpha.N`/`-beta.N` releases during migration.
- Staged rollout: after publishing, edit `stagingPercentage: 10` into the release's `latest-mac.yml`, then raise as crash metrics stay clean.
- Rollback: a pulled release must be superseded by a **higher** version — users on the broken build will not reinstall an equal one. (A blocked-versions kill-switch was removed as unwired dead code; reintroduce it in `updater.ts` if a remote config source ever exists to feed it.)
Expand Down
47 changes: 42 additions & 5 deletions apps/desktop/scripts/ensure-pty-prebuilds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,23 @@
* one. That is why this build needs no `x64ArchFiles` rule.
*/
import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { createHash, timingSafeEqual } from 'node:crypto'
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'

const logger = createLogger('DesktopPtyPrebuilds')

const REQUIRED_ARCHES = ['darwin-arm64', 'darwin-x64'] as const

Expand Down Expand Up @@ -49,8 +62,30 @@ function packageDir(arch: string): string {
return join(workspaceRoot, 'node_modules', '@lydell', `node-pty-${arch}`)
}

function expectedIntegrity(arch: string, version: string): string {
const packageName = `@lydell/node-pty-${arch}`
const prefix = `"${packageName}": ["${packageName}@${version}"`
const entry = readFileSync(join(workspaceRoot, 'bun.lock'), 'utf8')
.split('\n')
.find((line) => line.trimStart().startsWith(prefix))
const integrity = entry ? /,\s*"(sha512-[^"]+)"\],?$/.exec(entry)?.[1] : undefined
if (!integrity) {
throw new Error(`Could not find the pinned integrity for ${packageName}@${version}`)
}
return integrity
}

function verifyIntegrity(bytes: Buffer, integrity: string, packageName: string): void {
const expected = Buffer.from(integrity.slice('sha512-'.length), 'base64')
const actual = createHash('sha512').update(bytes).digest()
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
throw new Error(`Integrity check failed for ${packageName}`)
}
}

async function fetchPrebuild(arch: string, version: string): Promise<void> {
const name = `node-pty-${arch}`
const packageName = `@lydell/${name}`
const url = `https://registry.npmjs.org/@lydell/${name}/-/${name}-${version}.tgz`
const response = await fetch(url)
if (!response.ok) {
Expand All @@ -60,7 +95,9 @@ async function fetchPrebuild(arch: string, version: string): Promise<void> {
const staging = mkdtempSync(join(tmpdir(), 'sim-pty-prebuild-'))
try {
const tarball = join(staging, 'package.tgz')
writeFileSync(tarball, Buffer.from(await response.arrayBuffer()))
const bytes = Buffer.from(await response.arrayBuffer())
verifyIntegrity(bytes, expectedIntegrity(arch, version), packageName)
writeFileSync(tarball, bytes)
execFileSync('tar', ['-xzf', tarball, '-C', staging], { stdio: 'pipe' })

const target = packageDir(arch)
Expand All @@ -77,10 +114,10 @@ async function run(): Promise<void> {
for (const arch of REQUIRED_ARCHES) {
const dir = packageDir(arch)
if (existsSync(join(dir, 'prebuilds', arch, 'pty.node'))) {
console.log(`• node-pty prebuild present: ${arch}`)
logger.info('node-pty prebuild present', { arch })
continue
}
console.log(`• Fetching node-pty prebuild: ${arch}@${version}`)
logger.info('Fetching node-pty prebuild', { arch, version })
await fetchPrebuild(arch, version)
if (!existsSync(join(dir, 'prebuilds', arch, 'pty.node'))) {
throw new Error(`Downloaded @lydell/node-pty-${arch} but pty.node is missing`)
Expand All @@ -89,6 +126,6 @@ async function run(): Promise<void> {
}

run().catch((error) => {
console.error(error)
logger.error('Could not ensure node-pty prebuilds', { message: getErrorMessage(error) })
process.exit(1)
})
44 changes: 19 additions & 25 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ function main(): void {
let appSession: Session | null = null
let sessionLifecycle: ReturnType<typeof createSessionLifecycleCoordinator> | null = null
let resumingQuitAfterTeardown = false
let mandatoryRelaunchPending = false
let committedRelaunchPending = false
let tray: TrayHandle | null = null
let updater: UpdaterHandle | null = null
let startupReady: Promise<void> | null = null
Expand Down Expand Up @@ -383,7 +383,7 @@ function main(): void {
preloadPath,
isPackaged: app.isPackaged,
restorePosition,
isMandatoryRelaunchPending: () => mandatoryRelaunchPending,
isCommittedRelaunchPending: () => committedRelaunchPending,
onFullScreenChange: (isFullScreen) => {
if (!win.isDestroyed()) {
win.webContents.send('desktop:window-state:changed', { isFullScreen })
Expand Down Expand Up @@ -418,7 +418,7 @@ function main(): void {
}
},
allowHttpLocalhost: allowHttpLocalhost(),
isMandatoryRelaunchPending: () => mandatoryRelaunchPending,
isCommittedRelaunchPending: () => committedRelaunchPending,
})
attachContextMenu(win.webContents, {
isDev: !app.isPackaged,
Expand Down Expand Up @@ -554,7 +554,7 @@ function main(): void {
},
completeDeploymentScopedStateChange: completeDeploymentScopedTeardown,
relaunch: () => {
mandatoryRelaunchPending = true
committedRelaunchPending = true
relaunchApp()
},
})
Expand Down Expand Up @@ -585,11 +585,11 @@ function main(): void {
if (!resumingQuitAfterTeardown && sessionLifecycle?.isTeardownActive()) {
event.preventDefault()
void sessionLifecycle.awaitTeardown().then((clean) => {
if (!clean && !mandatoryRelaunchPending) {
if (!clean && !committedRelaunchPending) {
logger.error('Quit cancelled because account teardown did not finish safely')
return
}
if (!mandatoryRelaunchPending && !prepareAccountDataTeardownForQuit()) {
if (!committedRelaunchPending && !prepareAccountDataTeardownForQuit()) {
logger.error('Quit cancelled because account-data recovery could not be persisted')
return
}
Expand All @@ -603,36 +603,27 @@ function main(): void {
})
return
}
/**
* A mandatory relaunch is requested only after the server-switch transaction
* has cleared deployment-scoped capabilities and committed the replacement
* origin. The ordinary quit guard must not strand that committed process on
* its old partition; any retained marker is startup retry metadata.
*/
if (!mandatoryRelaunchPending && !prepareAccountDataTeardownForQuit()) {
// A committed relaunch is requested only after its prerequisite teardown has
// succeeded. The ordinary quit guard must not strand that committed process;
// any retained marker is startup retry metadata.
if (!committedRelaunchPending && !prepareAccountDataTeardownForQuit()) {
event.preventDefault()
logger.error('Quit cancelled because account-data recovery could not be persisted')
return
}
// Stops the tray's background chat refresh alongside the OS handles.
})

app.on('will-quit', () => {
// Renderer unload guards have accepted the quit, so native resources can
// now be released without leaving a cancelled quit in a degraded state.
tray?.destroy()
tray = null
localFilesystem.close()
// Quiesce native pages before publishing the final encrypted descriptor
// set. This prevents a navigation event racing the synchronous quit flush.
quiesceBrowserSessions()
terminal.dispose()
uninstallDocumentationHelpSearch()
flushDesktopChatSessions('before-quit')
// Settings writes coalesce, so a change made in the last moments before
// quit is still pending here.
config.flush()
})

app.on('will-quit', () => {
// Final backstop for any descriptor dirtied while Electron was closing
// windows after before-quit.
flushDesktopChatSessions('will-quit')
config.flush()
})

app.on('activate', () => {
Expand Down Expand Up @@ -868,6 +859,9 @@ function main(): void {
events,
appOrigin,
autoDownload: () => config.get('autoDownloadUpdates') ?? true,
setRelaunchPending: (pending) => {
committedRelaunchPending = pending
},
beforeInstall: async () => {
if (!prepareAccountDataTeardownForQuit()) {
throw new Error(
Expand Down
Loading
Loading