feat: added authorization step when trying to update Stats as non-admin (#3153)

This commit is contained in:
Serhiy Mytrovtsiy
2026-05-02 20:17:05 +02:00
parent d6fd5588b6
commit 918e7dc2aa
2 changed files with 87 additions and 21 deletions
+63 -13
View File
@@ -174,10 +174,7 @@ public class Updater {
completion("DMG not found at \(dmg)")
return
}
if !FileManager.default.isWritableFile(atPath: pwd) {
completion("has no write permission on \(pwd)")
return
}
let needsElevation = !FileManager.default.isWritableFile(atPath: pwd)
let diff = (Int(Date().timeIntervalSince1970) - self.lastInstallTS) / 60
if diff <= 3 {
@@ -242,16 +239,28 @@ public class Updater {
print("Script staged at \(scriptDst)")
let task = Process()
task.executableURL = URL(fileURLWithPath: "/bin/bash")
task.arguments = [scriptDst, "--app", pwd, "--dmg", dmg, "--mount", mountPoint]
do {
try task.run()
} catch {
completion("failed to launch updater: \(error)")
return
let scriptArgs = [scriptDst, "--app", pwd, "--dmg", dmg, "--mount", mountPoint, "--user", String(getuid())]
if needsElevation {
if let err = self.runElevated("/bin/bash", args: scriptArgs) {
_ = self.runProcess("/usr/bin/hdiutil", ["detach", mountPoint, "-force"])
try? FileManager.default.removeItem(atPath: scriptDst)
try? FileManager.default.removeItem(atPath: mountPoint)
completion("elevated install failed: \(err)")
return
}
} else {
let task = Process()
task.executableURL = URL(fileURLWithPath: "/bin/bash")
task.arguments = scriptArgs
do {
try task.run()
} catch {
completion("failed to launch updater: \(error)")
return
}
}
print("Run updater.sh with app: \(pwd) and dmg: \(dmg)")
self.lastInstallTS = Int(Date().timeIntervalSince1970)
@@ -310,6 +319,47 @@ public class Updater {
return dict[kSecCodeInfoTeamIdentifier as String] as? String
}
private func runElevated(_ tool: String, args: [String]) -> String? {
var authRef: AuthorizationRef?
let createStatus = AuthorizationCreate(nil, nil, [], &authRef)
guard createStatus == errAuthorizationSuccess, let authRef else {
return "AuthorizationCreate failed (\(createStatus))"
}
defer { AuthorizationFree(authRef, [.destroyRights]) }
// AuthorizationExecuteWithPrivileges is deprecated since 10.7 but still functional;
// resolve via dlsym to avoid the compile-time deprecation warning.
typealias AEWPFn = @convention(c) (
AuthorizationRef,
UnsafePointer<CChar>,
AuthorizationFlags,
UnsafePointer<UnsafeMutablePointer<CChar>?>,
UnsafeMutablePointer<UnsafeMutablePointer<FILE>?>?
) -> OSStatus
guard let sym = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "AuthorizationExecuteWithPrivileges") else {
return "AuthorizationExecuteWithPrivileges unavailable"
}
let aewp = unsafeBitCast(sym, to: AEWPFn.self)
var cArgs: [UnsafeMutablePointer<CChar>?] = args.map { strdup($0) }
cArgs.append(nil)
defer { cArgs.forEach { free($0) } }
let result: OSStatus = tool.withCString { toolPtr in
cArgs.withUnsafeMutableBufferPointer { buf in
aewp(authRef, toolPtr, [], buf.baseAddress!, nil)
}
}
if result == errAuthorizationCanceled {
return "user canceled"
}
if result != errAuthorizationSuccess {
return "AuthorizationExecuteWithPrivileges failed (\(result))"
}
return nil
}
private func runProcess(_ launch: String, _ args: [String]) -> (output: String, error: String, exit: Int32) {
let task = Process()
task.executableURL = URL(fileURLWithPath: launch)
+24 -8
View File
@@ -1,24 +1,40 @@
#!/bin/bash
set -eu
DMG_PATH="$HOME/Downloads/Stats.dmg"
MOUNT_PATH="/tmp/Stats"
APPLICATION_PATH="/Applications/"
LAUNCH_UID=""
STEP=""
while [[ "$#" > 0 ]]; do case $1 in
while [[ "$#" -gt 0 ]]; do case "$1" in
-s|--step) STEP="$2"; shift;;
-d|--dmg) DMG_PATH="$2"; shift;;
-a|--app) APPLICATION_PATH="$2"; shift;;
-m|--mount) MOUNT_PATH="$2"; shift;;
-u|--user) LAUNCH_UID="$2"; shift;;
*) echo "Unknown parameter passed: $1"; exit 1;;
esac; shift; done
if [[ "$STEP" == "2" ]]; then
rm -rf $APPLICATION_PATH/Stats.app
cp -rf $MOUNT_PATH/Stats.app $APPLICATION_PATH/Stats.app
APP_DST="${APPLICATION_PATH%/}/Stats.app"
APP_SRC="${MOUNT_PATH%/}/Stats.app"
$APPLICATION_PATH/Stats.app/Contents/MacOS/Stats --dmg "$DMG_PATH"
# When the script runs as root (admin auth path) but a target UID was passed,
# launch the new app back as the original user so it doesn't run as root.
launch_app() {
if [[ -n "$LAUNCH_UID" && "$(id -u)" == "0" ]]; then
/bin/launchctl asuser "$LAUNCH_UID" /usr/bin/sudo -u "#$LAUNCH_UID" "$@"
else
"$@"
fi
}
if [[ "$STEP" == "2" ]]; then
rm -rf "$APP_DST"
cp -rf "$APP_SRC" "$APP_DST"
launch_app "$APP_DST/Contents/MacOS/Stats" --dmg "$DMG_PATH"
echo "New version started"
elif [[ "$STEP" == "3" ]]; then
@@ -28,10 +44,10 @@ elif [[ "$STEP" == "3" ]]; then
echo "Done"
else
rm -rf $APPLICATION_PATH/Stats.app
cp -rf $MOUNT_PATH/Stats.app $APPLICATION_PATH/Stats.app
rm -rf "$APP_DST"
cp -rf "$APP_SRC" "$APP_DST"
$APPLICATION_PATH/Stats.app/Contents/MacOS/Stats --dmg-path "$DMG_PATH" --mount-path "$MOUNT_PATH"
launch_app "$APP_DST/Contents/MacOS/Stats" --dmg-path "$DMG_PATH" --mount-path "$MOUNT_PATH"
echo "New version started"
fi