diff --git a/.github/scripts/send-quit.swift b/.github/scripts/send-quit.swift new file mode 100644 index 00000000..c50164b2 --- /dev/null +++ b/.github/scripts/send-quit.swift @@ -0,0 +1,38 @@ +// Sends a process the quit Apple event macOS sends it, and prints the error number its reply +// carries, 0 for none. Used by the tray-install job on macOS, which has no logout to give the +// tray: `send-quit ` is a quit from the Dock or AppleScript, and `send-quit logout` is +// the one a logout sends, the quit with kAEQuitReason set to kAELogOut. +// +// A quit the app refuses is answered userCanceledErr (-128), which is how a logout learns it was +// refused, and cancelled. A quit the app lets through ends it before it replies, so the send +// fails on a connection that has gone instead, which is printed as its error number too. + +import Foundation + +let arguments = CommandLine.arguments +guard arguments.count >= 2, let pid = Int32(arguments[1]) else { + FileHandle.standardError.write("usage: send-quit [logout]\n".data(using: .utf8)!) + exit(2) +} + +let event = NSAppleEventDescriptor( + eventClass: AEEventClass(kCoreEventClass), + eventID: AEEventID(kAEQuitApplication), + targetDescriptor: NSAppleEventDescriptor(processIdentifier: pid), + returnID: AEReturnID(kAutoGenerateReturnID), + transactionID: AETransactionID(kAnyTransactionID)) + +// An attribute of the event, not a parameter, which is where loginwindow puts it and where +// TerminateGuard reads it from. +if arguments.count >= 3, arguments[2] == "logout" { + event.setAttribute( + NSAppleEventDescriptor(enumCode: OSType(kAELogOut)), + forKeyword: AEKeyword(kAEQuitReason)) +} + +do { + let reply = try event.sendEvent(options: [.waitForReply], timeout: 10) + print(reply.paramDescriptor(forKeyword: AEKeyword(keyErrorNumber))?.int32Value ?? 0) +} catch let error as NSError { + print(error.code) +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f86f59dc..5d18920a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -378,7 +378,8 @@ jobs: # The packaging tests read them; only `dotnet tool install` choosing between them shows the # right one is found for a machine, and only starting what it laid out shows that it runs. So # this installs the tray from the windows job's packages, on each platform, as someone would - # from nuget.org, and waits for the installed tray to listen on the piper port. + # from nuget.org, and waits for the installed tray to listen on the piper port. On macOS it then + # quits the tray both ways macOS does, a logout's quit among them. # # From a source of those packages alone. Until a release, their version is the one in # Directory.Build.props, which nuget.org already has as an older tray, and an install that could @@ -437,6 +438,77 @@ jobs: cat "$tool/logs/log.txt" || true kill "$pid" || true [ -n "$listening" ] || { echo "::error::The installed tray did not listen on 3492"; exit 1; } + wait "$pid" || true + echo "tray=$tray" >> "$GITHUB_ENV" + echo "tool=$tool" >> "$GITHUB_ENV" + + # The quits macOS sends, by Apple event as it sends them: an ordinary one, which the tray + # refuses and turns into its own exit, and a logout's, which it has to let through or the + # logout is cancelled. CI cannot log out, so the logout's is sent by hand, with the reason a + # logout gives it. Either way the tray exits and takes its version marker with it; after a + # logout's, only the session end it ran can have done that, since AppKit ends the process + # without the loop's own way out. + - name: Quit the tray as macOS does + if: runner.os == 'macOS' + shell: bash + run: | + xcrun swiftc -O -o "$RUNNER_TEMP/send-quit" .github/scripts/send-quit.swift + marker="${TMPDIR%/}/DiffEngineTray/version.txt" + + quit() { + # Listening by its own process id, since that is what the events are sent to + "$tray/DiffEngineTray" & + pid=$! + listening= + for i in $(seq 1 30); do + if lsof -nP -a -p "$pid" -iTCP:3492 -sTCP:LISTEN > /dev/null 2>&1; then + listening=1 + break + fi + sleep 1 + done + [ -n "$listening" ] || { cat "$tool/logs/log.txt" || true; echo "::error::The tray did not listen on 3492"; exit 1; } + if grep -qs "No status item" "$tool/logs/log.txt"; then + cat "$tool/logs/log.txt" || true + echo "::error::The tray has no status item, so nothing pumps the Apple events sent to it" + exit 1 + fi + test -f "$marker" || { echo "::error::The tray did not write $marker"; exit 1; } + + code=$("$RUNNER_TEMP/send-quit" "$pid" $1) + echo "A ${1:-plain} quit was answered $code" + + exited= + for i in $(seq 1 20); do + state=$(ps -o stat= -p "$pid" | tr -d ' ' || true) + if [ -z "$state" ] || [[ "$state" == Z* ]]; then + exited=1 + break + fi + sleep 0.5 + done + if [ -z "$exited" ]; then + kill "$pid" || true + cat "$tool/logs/log.txt" || true + echo "::error::The tray was still running ten seconds after a ${1:-plain} quit" + exit 1 + fi + wait "$pid" && status=0 || status=$? + [ "$status" = 0 ] || { cat "$tool/logs/log.txt" || true; echo "::error::The tray exited $status after a ${1:-plain} quit"; exit 1; } + ! test -f "$marker" || { echo "::error::The tray left $marker behind after a ${1:-plain} quit"; exit 1; } + } + + quit + [ "$code" = -128 ] || { echo "::error::A plain quit was answered $code, where the tray refuses it (-128) and exits by its own loop"; exit 1; } + + quit logout + [ "$code" != -128 ] || { echo "::error::A logout's quit was refused (-128), which cancels the logout"; exit 1; } + + cat "$tool/logs/log.txt" || true + if grep -qs "Failed while the session was ending" "$tool/logs/log.txt"; then + echo "::error::The session end failed" + exit 1 + fi # On Windows the command has to be a GUI apphost as well: a Run key naming a console one, or # a .cmd, opens a console at every login. diff --git a/claude.md b/claude.md index 3cf0bec2..522e4d7c 100644 --- a/claude.md +++ b/claude.md @@ -795,7 +795,12 @@ the comment there about not caching "nothing staged" asks for. - CI's `tray-install` job installs the tray from the `windows` job's packages on Windows, Linux and macOS, from a source of those alone, checks the viewer and documents folder are where the lookups expect and (on Windows) that the command is a GUI apphost, then starts it and waits for - it to listen on 3492. `publish` waits for it + it to listen on 3492. On macOS it then sends the tray the quit Apple events macOS sends + (`.github/scripts/send-quit.swift`, by pid): an ordinary one, which has to be answered -128 and + end the tray by its own loop, and a logout's, `kAEQuitReason` set to `kAELogOut`, which must + not be answered -128, the refusal that cancels a logout. Both have to end the tray with status + 0 and its version marker gone. CI cannot log out, so this is the nearest to one it has. + `publish` waits for it - Update is PlatformHeads' `ToolUpdate` on every head, through the core's `TrayUpdate`, which finds the shim from where the tray runs (`ToolUpdate.ShimFor`, null for a build's output) and keeps the outcome beside the settings, out of the store the update replaces. A shell waits for