Curb NSTemporaryDirectory abuse in xip unarchive

Happy new year.

Here's something annoying. When you unarchive a xip archive, like the ones that Xcode releases come in, the unarchive happens to a temporary directory that lives on the root volume, even if the archive doesn't, and even if the result of the unarchive is ultimately going back to the secondary volume.

So, for example, if you are unarchiving /Volumes/ExternalHDD/Xcode.xip, the files get unarchived to /private/var/folders/foo/bar/T/<some UUID>, and then the resultant goes back to /Volumes/ExternalHDD/

This happens if you use Archive Utility. This happens if you use the xip command-line tool.

It's all because PackageKit, the framework that underlies both tools, is lazy and uses NSTemporaryDirectory() to generate a temporary working directory. It should really be using -[NSFileManager URLForDirectory:inDomain:appropriateForURL:create:error:] with NSItemReplacementDirectory, but to be fair that API only came around recently, you know, in 2009.

Anyway, this is dumb for several reasons. It results in extra copies, sure. But the whole reason I put Xcode.xip on an external disk is that I can't spare the half a terabyte or whatever that out-of-box Xcode installs take up now. Not on my internal disk.

This sort of thing happens all over the place in Apple's installer stack. I guess they consider external disks a corner case that doesn't merit testing.

Working around this is pretty easy. Just set a breakpoint on NSTemporaryDirectory() and return a more appropriate working directory. In my case, I set up a /Volumes/ExternalHDD/tmp/ and then did this:

$ lldb -- xip --expand Xcode_13.4.1.xip
(lldb) target create "xip"
Current executable set to 'xip' (x86_64).
(lldb) settings set --  "--expand" "Xcode_13.4.1.xip"
(lldb) b NSTemporaryDirectory
Breakpoint 1: where = Foundation`NSTemporaryDirectory, address = 0x00007fff211727c7
(lldb) run
Process 4370 launched: '/usr/bin/xip' (x86_64)
xip: signing certificate was "Software Update" (validation not attempted)
Process 4370 stopped
* thread #3, queue = '', stop reason = breakpoint 1.1
    frame #0: 0x00007fff211837c7 Foundation`NSTemporaryDirectory
->  0x7fff211837c7 <+0>: pushq  %rbp
    0x7fff211837c8 <+1>: movq   %rsp, %rbp
    0x7fff211837cb <+4>: pushq  %r15
    0x7fff211837cd <+6>: pushq  %r14
Target 0: (xip) stopped.
(lldb) bt
* thread #3, queue = '', stop reason = breakpoint 1.1
  * frame #0: 0x00007fff211837c7 Foundation`NSTemporaryDirectory
    frame #1: 0x00007fff3b95dd83 PackageKit`-[PKSignedContainer _startUnarchivingAtPath:cancelHandler:notifyOnQueue:progress:finish:] + 92
    frame #2: 0x00007fff3b95f657 PackageKit`__74-[PKSignedContainer startUnarchivingAtPath:notifyOnQueue:progress:finish:]_block_invoke + 69
    frame #3: 0x00007fff20174623 libdispatch.dylib`_dispatch_call_block_and_release + 12
    frame #4: 0x00007fff20175806 libdispatch.dylib`_dispatch_client_callout + 8
    frame #5: 0x00007fff20177e37 libdispatch.dylib`_dispatch_queue_override_invoke + 775
    frame #6: 0x00007fff20184818 libdispatch.dylib`_dispatch_root_queue_drain + 326
    frame #7: 0x00007fff20184f70 libdispatch.dylib`_dispatch_worker_thread2 + 92
    frame #8: 0x00007fff2031c417 libsystem_pthread.dylib`_pthread_wqthread + 244
    frame #9: 0x00007fff2031b42f libsystem_pthread.dylib`start_wqthread + 15
(lldb) p (id)[@"/Volumes/ExternalHDD/tmp/" copyWithZone:0]
(__NSCFString *) $0 = 0x000000010020a260 @"/Volumes/ExternalHDD/tmp/"
(lldb) thread return 0x000000010020a260
* thread #3, queue = '', stop reason = breakpoint 1.1
    frame #0: 0x00007fff3b95dd83 PackageKit`-[PKSignedContainer _startUnarchivingAtPath:cancelHandler:notifyOnQueue:progress:finish:] + 92
PackageKit`-[PKSignedContainer _startUnarchivingAtPath:cancelHandler:notifyOnQueue:progress:finish:]:
->  0x7fff3b95dd83 <+92>:  testq  %rax, %rax
    0x7fff3b95dd86 <+95>:  movq   %r14, -0xae8(%rbp)
    0x7fff3b95dd8d <+102>: je     0x7fff3b95e1db            ; <+1204>
    0x7fff3b95dd93 <+108>: movq   %rax, %rbx
(lldb) c
Process 4370 resuming

It only ever asks once, and it seems always to be the first hit of the breakpoint. So pleasant! And then it does exactly what you would want.