/dev/trouble
Eric Roller's Development Blog

Up until today, I have been using a Build Phase script to update the CFBundleVersion in the Info.plist file using a script like this:

#!/bin/bash

if [[ ! -d .git ]]; then
  echo "Error: Git setup not found: ./.git"
  exit 1
fi

# The number of commits in the master branch.
rev=$(expr $(git rev-list master --count) - $(git rev-list HEAD..master --count))

echo "Updating build number to $rev:"

for plist in 
    "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}" 
    ; do
  if [ -f "$plist" ]; then
    echo " -> $(file "$plist")"
    /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $rev" "$plist"
    plutil -lint -s "$plist"
  else
    echo " ?? $plist"
  fi
done

Sadly, with Xcode 15, this stopped working. Whether or not ENABLE_USER_SCRIPT_SANDBOXING was enabled or not, Xcode no longer allowed access to the plist file (or complained about a circular dependency).

So, now I am using a build configuration file to set the build number; the config file needs to be added to the Xcode project, and selected in the project "Configurations". My file contains just:

// proj.xcconfig
CURRENT_PROJECT_VERSION = 0

Also, the Info.plist file needs to be updated to use:

<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>

My new methodology uses a "Build pre-actions" script (added in the target's scheme editor and using the build settings from the target) to update the .xcconfig file before every build:

#!/bin/bash
cd "$SOURCE_ROOT"

if [[ ! -d .git ]]; then
  echo "Error: Git setup not found: ./.git"
  exit 1
fi

# The number of commits in the master branch.
rev=$(expr $(git rev-list master --count) - $(git rev-list HEAD..master --count))

echo "Updating build number to $rev:"

sed -i '' "/CURRENT_PROJECT_VERSION/ { s/=.*/= $rev/; }" ./*.xcconfig

But since the .xcconfig file is also a revision-controlled file, I reset the value again in a "Build post-actions" script. Similar to using git restore to revert the changes:

#!/bin/bash
cd "$SOURCE_ROOT"
sed -i '' '/CURRENT_PROJECT_VERSION/ { s/=.*/= 0/; }' ./*.xcconfig

Explanation of the sed code:

  • For any file matching *.xcconfig,
  • modify it "in place" (not creating a backup file),
  • all lines containing "CURRENT_PROJECT_VERSION",
  • substitute text beginnig with "=" to "= $rev" or "= 0".

Update 2024-01-11

Finally, I added a build configuration script to make sure that the $(CURRENT_PROJECT_VERSION) variable contains a non-zero value:

#!/bin/sh
ok=0
if [ ${CURRENT_PROJECT_VERSION:-0} -eq 0 ]; then
  echo 'Error: CURRENT_PROJECT_VERSION has not been set.'
  ok=1
fi
exit $ok

Framing iPhone 14 screenshots

- Posted in iOS by

When trying to place generated screenshots into device frames (automated by fastlane's frameit), I discovered that, since the iPhone 14 devices have small bezels, the corners of the screenshot can be seen spilling over to outside of the template frames for these devices:

trim_devices = [
    "iPhone 14 Plus",
    "iPhone 14 Pro",
    "iPhone 14 Pro Max",
]

I solved this by cutting the corners off of the screenshot images before running frameit. This can be done by three ImageMagick commands, creating a rounded-rect image with 50-pixel-radius curved corners:

`convert '#{file}' 
    -format 'roundrectangle 1,1 %[fx:w+4],%[fx:h+4] 50,50' 
    info: > mask.mvg`
`convert '#{file}' 
    -alpha transparent 
    -background none 
    -fill white 
    -stroke none 
    -strokewidth 0 
    -draw "@mask.mvg" mask.png`
`convert '#{file}' 
    -alpha set 
    -bordercolor none 
    mask.png 
    -compose DstIn 
    -composite '#{file_name}'`

In my setup, I am operating in a temporary directory (with a language subdirectory, e.g. "en-US"):

Dir.mktmpdir do |tmp|
  path_temp = File.join(tmp, language)
  Dir.mkdir(path_temp)
  puts "Operating in #{path_temp}"

  num_found = 0
  # ...(see below)...
end

Then for each of the devices, depending on whether it is a large iPhone 14 device or not, I either create a corner-cropped image in the temporary directory or place a copy of - or rather a symbolic link to - the original image:

path_shot = File.absolute_path(File.join(
      "..", output_directory, language
  ))

run_devices.each do |device|
  Dir.glob(File.join(path_shot, device + "-*.png")).each do |file|
    file_name = File.basename(file)
    Dir.chdir(path_temp) do
      if trim_devices.include? device
        # Cut off rounded corners using ImageMagick:
        puts "Rounded frame for: #{file_name}"
        `convert '#{file}' -format 'roundrectangle 1,1 %[fx:w+4],%[fx:h+4] 50,50' info: > mask.mvg`
        `convert '#{file}' -alpha transparent -background none -fill white -stroke none -strokewidth 0 -draw "@mask.mvg" mask.png`
        `convert '#{file}' -alpha set -bordercolor none mask.png -compose DstIn -composite '#{file_name}'`
        Dir.glob("mask.*").each { |mask| File.delete(mask) }
      else
        puts "Linking frame for: #{file_name}"
        File.symlink(file, file_name)
      end
    end
    num_found += 1
  end
end

After adding Framefile.json as well as any other needed files into the temporary directory, frameit can be called to do its work there:

frame_screenshots(
    use_platform: "IOS",
    path: tmp
)

Thereafter the "*_framed.png" images need to be copied before the temporary directory is deleted. Here is an example where the result frames are placed in a separate "en-US_framed" directory:

path_fram = File.absolute_path(File.join(
    "..", output_directory, language + "_framed"
))

Dir.glob(File.join(path_temp, "*_framed.png")).each do |file|
  file_name = File.basename(file).sub(/_framed/, "")
  puts "Frame generated: #{file_name}"
  FileUtils.cp(file, File.join(path_fram, file_name))
end

In my setup, fastlane usually runs on a separate computer in my closet. Therefore I find it helpful to add audio messages such that I can hear the status:

sh 'say "Creating screenshots for iOS devices."'

Fastlane's say() command sometimes hasn't worked for me. That's why I have used sh 'say'. However, that was recorded by fastlane as an action. Using this Ruby syntax is not tracked as an action:

`say "Processing images."`

But neither does its produce any text in the log. So I created this Ruby function which prints the message and then speaks it:

def talk(s) puts s; `say "#{s}"` end

With the function anywhere in fastlanes Fastfile, one can just call it like a command:

talk "Framing screenshots"

Speaking Multiple Phrases

This all works nicely, but I discovered that my error message needs to be made more prominent. I might be in the bathroom and don't hear the announcement. That's why I have now changed it to be a lot longer and a lot more annoying.

error do |lane, exception|
  phrases = [
      "Are you serious?",
      "Huston, we've had a problem!",
      "I can't believe it!",
      "I thought you had fixed that.",
      "Is it broken again?",
      "Maybe we can just reboot it?",
      "No! No! No, not again!",
      "Oops! I did it again!",
      "Something smells fishy!",
      "There is a left-over screw here.",
      "There must be a rational explanation for this.",
      "This must be a mistake!",
      "What if we pressed the red button?",
      "What is going on?",
      "What is this switch for?",
      "Where's the beef?",
      "Where is the user manual?",
      "Who pressed the red button?",
      "Who let the dogs out?",
      "You must be joking!",
  ]

  `say "#{ phrases.shuffle.join(" [[slnc 2500]] ") }"`
end

Note that the phrases are shuffled and concatenated by [[sinc 2500]] instructions which add 2.5-second-long pauses between the phrases. I found that in Apple's Speech Synthesis Programming Guide.

Whenever I like to show or hide view elements, I stumble over the hidden() view property:

struct MyView: View {
    var body: some View {
        Text("Visible")

        Text("Not visible")
            .hidden()
    }
}

What perplexes me is why anyone would need this? If it is always hidden, then why add it in the first place??

Well, I guess it could be used to create some well-defined, empty space in the view layout.

Now, this is what I would really have a use for:

struct MyView: View {
    var show_info: Bool

    var body: some View {
        Text("Visible")

        Text("Sometimes visible")
            .hide(!show_info)    // OBS! Doesn't exist!
    }
}

Depending on whether or not the hidden part should be used when laying out the view, a different approach is needed. Either without taking any space:

struct MyView: View {
    var show_info: Bool

    var body: some View {
        Text("Visible")

        if show_info {
            // Not occupying space when hidden
            Text("Sometimes visible")
        }
    }
}

Or occupying space:

struct MyView: View {
    var show_info: Bool

    var body: some View {
        Text("Visible")

        // Taking space, even if transparent
        Text("Sometimes visible")
            .opacity(show_info ? 1.0 : 0.0)
    }
}

Creating screenshots is a tedious task that lends itself to automation. Ideally, it would be preferrable if it were possible to have simple, device-idependent testing scripts. The reality is that many exceptions creep into the code that simulates the UI interactions when generating the screenshots:

if UIDevice.current.userInterfaceIdiom == .phone {
    // Do something different for iPhones.
}

But the real problem is maintaining these scripts. Whether it is iOS SDK changes, new device names, new screen sizes, or indeed new apps features, suddenly the scripts no longer work. Even if everything worked nicely last time, the tiniest of changes will trigger hours of UI scripts re-tuning.

I usually find myself having to re-run fastlane over and over in order to create new screen shots. Quite often, the scripts work well for some devices but fail for others. At that point it would be nice if fastlane could be instructed to only re-run the scripts for the devices that had failed. But fastlane has no option for that.

But it is possible to re-code the Fastfile since it is essentially a Ruby script. All I need is a simple check whether the final screenshot exists for a device. When present, don't re-run the lengthy UI scripts for that device.

So here are the relevant parts of my new ./fastlane/Fastfile; beware custom names like MyApp:

delete_all_screenshots = false

swift_macro_defs =
    "SWIFT_ACTIVE_COMPILATION_CONDITIONS='FASTLANE'"
output_directory = "./screenshots/fastlane"

# All languages and devices to run:
all_languages = [ "en-us" ]

all_devices = [
    "iPad Pro (9.7-inch)",
    "iPad Pro (10.5-inch)",
##  "iPad Pro (11-inch)",
    "iPad Pro (11-inch) (4th generation)",
    "iPad Pro (12.9-inch) (2nd generation)",
    "iPad Pro (12.9-inch) (6th generation)",
    "iPhone 8",
    "iPhone 8 Plus",
    "iPhone 14",
    "iPhone 14 Plus",
    "iPhone 14 Pro",
    "iPhone 14 Pro Max",
    "Apple TV",
    "Apple TV 4K (3rd generation)",
]

platform ::ios do
  desc "Take screenshots (iOS)"
  lane :ios_screenshot do

    all_languages.each do |language|
      # Work out the path to the screenshots.
      # Beware: CWD is the directory of this file!
      path_shot = File.join(
          "..", output_directory, language
      )

      run_devices = Array.new

      all_devices.each do |device|
        # Neither 'Apple TV' nor 'Apple Watch' devices
        next if device.start_with? "Apple"

        unless delete_all_screenshots then
          # If the last screenshot exists, skip this device:
          last_shot = device + "-10_Exercises.lite.png"
          last_shot = device + "-09_Exercises.lite.png" \
              if device.start_with? "iPad"

##        puts "Checking if file exists: #{last_shot}"
          file_shot = File.join(path_shot, last_shot)
          next if File.exists?(file_shot)
        end

        run_devices << device
      end

      next if run_devices.empty?

      snapshot(
          project: "./MyApp.xcodeproj",
          scheme: "MyApp-iOS",
          xcargs: swift_macro_defs,
          devices: run_devices,
          languages: [ language ],
          localize_simulator: true,
          reinstall_app: true,
          headless: false,
          concurrent_simulators: false,
          clean: delete_all_screenshots,
          clear_previous_screenshots:
              delete_all_screenshots,
          stop_after_first_error: true,
          number_of_retries: 0,
          override_status_bar: true,
          test_without_building: false,
          output_directory: output_directory,
          disable_slide_to_type: true,
          skip_open_summary: true,
          only_testing: [
              "UITests-iOD/UITests_Fastlane",
          ],
      )

      delete_all_screenshots = false
    end
  end
end

If you want to recreate all screenshots, you could set delete_all_screenshots to true or, well, delete the files manually.

Also note the SWIFT_ACTIVE_COMPILATION_CONDITIONS compiler flag. This allows me to define a FASTLANE macro that can be used in the app's Swift code:

#if FASTLANE
loadExampleData()
#endif

Time Machine can be configured to exclude folders from your backups (see System Settings > General > Time Machine > Options). I usually exclude these:

  • /Library/Caches
  • ~/Library/Caches
  • ~/Library/Developer/CoreSimulator
  • ~/Library/Developer/XCPGDevices
  • ~/Library/Developer/XCTestDevices
  • ~/Library/Developer/Xcode/DerivedData
  • ~/Library/Developer/Xcode/DocumentationCache
  • ~/Library/Developer/Xcode/iOS DeviceSupport
  • ~/Library/Developer/Xcode/tvOS DeviceSupport
  • ~/Library/Developer/Xcode/watchOS DeviceSupport
  • ~/Library/Logs/CoreSimulator

Also, if you need to make space to be able to install Xcode or a macOS update, you can delete the contents of the above folders (assuming you no longer need old device SDKs).

When the NumberFormatter() is asked to output Decimal/NSDecimalNumber values with more than 15 fractional digits, the output is rounded (apparently to the precision of Double numbers). Example:

14 decimals: 3.14159265358979
15 decimals: 3.141592653589793
16 decimals: 3.1415926535897900
17 decimals: 3.14159265358979000
18 decimals: 3.141592653589790000
19 decimals: 3.1415926535897900000
20 decimals: 3.14159265358979000000

Note the trailing zeros which are not expected.

Note that Decimal numbers support a precision of 38-decimals. Example: Decimal.pi.description

3.14159265358979323846264338327950288419

Code to reproduce the rounding problem:

import Foundation

var number = Decimal.pi
let formatter = NumberFormatter()

for width in 10 ... 38 {
    formatter.maximumFractionDigits = width
    formatter.minimumFractionDigits = width
    print(String(width) + " decimals: " +
            (formatter.string(from: number as NSDecimalNumber) ?? ""))
}

Apple feedback ID: FB9835108

MC-Timer UI Design

- Posted in iOS by

For a new version of MC-Timer, I have been looking at reorganising the playback screen, especially the text pointed out here:

MC-Timer progress text below the rings. Song: Sia - The Greatest

Being near the bottom, I don't think people look at this much and combining step values with timing values is probably confusing.

So maybe it will look better when annotated directly over the progress rings? Thankfully SwiftUI makes it easy to experiment with different layouts.

MC-Timer progress text on the rings

Yes, this helps in the understanding of the values, but I had to drop "Music", "Pause", or "Countdown" which didn't look good when written as a curved text. However, the overall aesthetic suffers tremendously.

So it is probably better to keep the texts in the top corners:

MC-Timer progress text in the top corners

This looks much better and the grouping of the timing values on the left vs. the step values on the right helps also. There is no additional information what the "8 / 121" values mean but it does become apparent whenever the red progress bar increments.

But the wide "15s Pause" text spilling over into the top of the red is not ideal, so I will split that into two lines like this:

MC-Timer split progress text in the top corners

Neat!

My app "MC-Timer" supports playing music with a mixture of Apple Music curated playlists, (Apple Music catalog playlists,) Apple Music songs, library playlists as well as individual songs from your media library.

Playback for songs from your media library and for those streamed from Apple Music works well with the iOS media player.

However, for items in your own "catalog playlist", the Apple Music API will return playParams in the JSON response that may look like this:

"playParams": {
    "id": "i.DVENxPRTdlJoV",
    "kind": "song",
    "isLibrary": true,
    "reporting": false,
    "purchasedId": "253867849"
}

By the way, parsing this into a dictionary of type [String: Any] is a huge pain and I wish the media player API could just accept the JSON as is. Apple, please add: MPMusicPlayerPlayParameters(json: String).

To play this song, one can pass on the dictionary with the parameters to the music player as follows:

if let mppp = MPMusicPlayerPlayParameters(dictionary: playParams) {
    // …
}

However, it only captures part of the dictionary:

(lldb) po mppp
<MPMusicPlayerPlayParameters:0x280d983f0 {
    id = "i.DVENxPRTdlJoV";
    isLibrary = 1;
    kind = song;
}>

And when you try to play that:

player.setQueue(with: MPMusicPlayerPlayParametersQueueDescriptor(playParametersQueue: mppp))
player.play()

you will see:

2021-03-12 14:02:37.105160+0100 app[1626:732477] [tcp] tcp_input [C13.1:3] flags=[R] seq=2895265255, ack=0, win=0 state=LAST_ACK rcv_nxt=2895265255, snd_una=3660699433
2021-03-12 14:02:39.764732+0100 app[1626:732039] [SDKPlayback] Failed to prepareToPlay error: Error Domain=MPMusicPlayerControllerErrorDomain Code=6 "Failed to prepare to play" UserInfo={NSDebugDescription=Failed to prepare to play}

and the music player will play any random song from your library instead.

Neither does it work to play the song via its store identifier:

player.setQueue(with: [ "i.DVENxPRTdlJoV" ])

Apparently, this is a known problem for years, and it has not been fixed.

I hear that the purchaseId should be used instead, but this is undocumented. Also, if that is the case, the MPMusicPlayerPlayParameters should handle that under the hood.

On Apple TV with tvOS 14.4, once Apple Music access is enabled, the following commands will cause an app to crash:

let songStoreID = "900032829" 
let musicPlayer = MPMusicPlayerController.applicationQueuePlayer  // [1]
musicPlayer.setQueue(with: [ songStoreID ])
musicPlayer.prepareToPlay()    // <-- crashes here [2]
musicPlayer.play()   // if skipping [2], it would crash here instead

In the Xcode console log, I note these messages:

// [1]
[APSLogUtils] [AirPlayError] APSCopyPairingIdentity:627: got error 4099/0x1003 NSXPCConnectionInvalid
[MediaRemote] Error fetching local receiver pairing identity (OSStatus = 4099)

// [2]
[SDKPlayback] applicationQueuePlayer _establishConnectionIfNeeded timeout [ping did not pong]

Note: It does NOT crash when using MPMusicPlayerController.systemMusicPlayer.

Apple feedback id: FB8985422