Validate iOS calendar exports on a cloud Mac

Validate iOS calendar exports on a cloud Mac

Validate iOS calendar exports on a cloud Mac

A user imports an event exported by your app, only to find that commas have disappeared from the title and an all-day event spans an extra day. These problems usually have nothing to do with a failed build. They happen because the exporter treats strings, bytes, and dates as interchangeable. On a cloud Mac, you can run fixed test cases with Swift, inspect the ICS file, and then check the actual import result—without waiting for a user to send screenshots.

Define the validation scope first

This walkthrough covers single-event exports only: titles and descriptions are text, timed events use UTC, and all-day events use dates. Recurrence rules, attendee invitations, and reminders are out of scope. If you add those features later, create separate test cases rather than carrying forward a “the file opens, so it passes” standard.

Keep the event UID, DTSTAMP, and input dates fixed. In production, persist the UID with the event instead of generating a new one on every export. Calendar clients handle repeated imports differently, however, so a stable UID does not guarantee automatic updates.

Format checks answer “Does the file meet the specification?” Import checks answer “Does the user see the correct event?” Neither can replace the other.

Save the following script as calendar-check.swift in your working directory. It uses only Foundation and can run once the Xcode command-line tools are installed and selected.

Fold lines by bytes, not characters

ICS text values require escaping for backslashes, semicolons, commas, and newlines. Normalize input line endings first, then escape the text, and finally fold the lines. Reversing that order can escape the continuation markers themselves or cause escape sequences to be misinterpreted as ordinary text.

Aim to keep each physical line within 75 octets, excluding CRLF. A continuation line starts with a single space, which also counts as one byte. Do not use String.count to calculate capacity, and do not simply truncate a UTF-8 array: doing so can split Chinese characters or emoji and produce invalid encoding.

import Foundation

func escapeText(_ value: String) -> String {
    value
        .replacingOccurrences(of: "\r\n", with: "\n")
        .replacingOccurrences(of: "\r", with: "\n")
        .replacingOccurrences(of: "\\", with: "\\\\")
        .replacingOccurrences(of: ";", with: "\\;")
        .replacingOccurrences(of: ",", with: "\\,")
        .replacingOccurrences(of: "\n", with: "\\n")
}

func fold(_ value: String) -> String {
    var lines: [String] = []
    var current = ""
    var bytes = 0

    for scalar in value.unicodeScalars {
        let piece = String(scalar)
        let size = piece.utf8.count
        if bytes + size > 75 {
            lines.append(current)
            current = " "
            bytes = 1
        }
        current += piece
        bytes += size
    }

    lines.append(current)
    return lines.joined(separator: "\r\n")
}

let title = String(repeating: "项目复盘🧑🏽‍💻", count: 12)
let description = "范围,A;B\\C\r\n第二行"
let logicalLines = [
    "BEGIN:VCALENDAR",
    "VERSION:2.0",
    "PRODID:-//MiniVPS//Calendar Check//ZH",
    "BEGIN:VEVENT",
    "UID:calendar-check-001",
    "DTSTAMP:20260929T000000Z",
    "DTSTART:20260930T010000Z",
    "DTEND:20260930T020000Z",
    "SUMMARY:" + escapeText(title),
    "DESCRIPTION:" + escapeText(description),
    "END:VEVENT",
    "END:VCALENDAR"
]

let output = logicalLines.map(fold).joined(separator: "\r\n") + "\r\n"
let physicalLines = output.components(separatedBy: "\r\n")
precondition(physicalLines.last == "")
precondition(physicalLines.dropLast().allSatisfy {
    !$0.isEmpty && $0.utf8.count <= 75
})

let withoutCRLF = output.replacingOccurrences(of: "\r\n", with: "")
precondition(!withoutCRLF.contains("\r"))
precondition(!withoutCRLF.contains("\n"))

let unfolded = output.replacingOccurrences(of: "\r\n ", with: "")
precondition(unfolded == logicalLines.joined(separator: "\r\n") + "\r\n")
precondition(escapeText(description) == "范围\\,A\\;B\\\\C\\n第二行")

try Data(output.utf8).write(
    to: URL(fileURLWithPath: "calendar-check.ics"),
    options: .atomic
)

The loop appends one Unicode scalar at a time, ensuring that every physical line is valid UTF-8. A compound emoji may be split across two physical lines, but removing the continuation markers must restore the original text. The unfolding consistency check in the script guards against characters being lost or added during folding. It tests this example generator; it is not a general-purpose ICS parser.

Keep absolute times separate from all-day dates

Timed events use UTC values ending in Z to represent specific instants. When input comes from a local calendar, first resolve it to an instant using the user's selected time zone, then format it. Simply appending Z to a local time string is not sufficient.

All-day events, on the other hand, should not be converted to midnight UTC first. Replace the start and end properties in the example with:

DTSTART;VALUE=DATE:20260930
DTEND;VALUE=DATE:20261001

This represents September 30 only. The end date is exclusive, and the same rule applies across month and year boundaries. To calculate the next day in code, use date arithmetic with an explicitly chosen calendar rather than adding a fixed number of seconds to a timestamp.

If the product requires “9 a.m. local time every day,” a one-off UTC event cannot express that meaning. Time zone identifiers, time zone definitions, and recurrence rules need a separate design; the importing client cannot be expected to infer them.

Check semantics through an actual import

Run the script, transfer the generated file back to your local machine, and import it into a calendar reserved for testing:

xcrun swift calendar-check.swift
shasum -a 256 calendar-check.ics

Calculate the checksum again after transfer to confirm that you are testing the same file. Being able to view a file over remote desktop does not prove that its transfer or contents are correct.

Check each item: Are the Chinese characters and emoji intact? Are the commas, semicolons, and backslashes still present? Does the description contain exactly two lines? Does the timed event last one hour? Does the all-day event cover just one day? Validate at least once in every client the project actually supports. Do not treat one client's tolerance for malformed input as the format standard.

Add cases for an empty title, a very long description, an all-day event crossing a month boundary, and the same UTC event displayed in different time zones. When checking times manually, record the client's time zone as well, so that a correct conversion is not mistaken for an offset error.

Turn failures into evidence you can trace

In CI, retain the generated file, input sample identifier, script version, and process exit code. Stop delivery when a check fails; do not catch the error and continue uploading an old file. Real event titles and descriptions may contain private information, so retain only synthetic data in regression artifacts.

Split the validation gate into three layers: the byte layer checks CRLF and unfolding consistency; the property layer checks required fields and date types; and the semantic layer checks dates, duration, and text after import. When adding recurring events or reminders, specify which layer gets new rules and which failing test case is being added.

Finally, add a minimal negative test for the exporter: remove comma escaping, set an all-day event's end date to its start date, or truncate in the middle of a UTF-8 sequence. Only when the tests catch these faults are they genuinely protecting the export contract, rather than merely confirming that the current implementation runs.

Frequently asked questions

Does the ICS folding limit mean 75 characters?

The folding recommendation is 75 octets, not characters. Multibyte UTF-8 text consumes more space, and the leading space on a continuation line counts toward its length.

Should an all-day event end on its final included date?

No. DTEND is exclusive. A one-day event ends on the following date, with both DTSTART and DTEND using VALUE=DATE.

Move your next build to a cloud workstation

An entire Mac mini, exclusively yours—not a virtual machine. Choose a rental term that fits your project; availability is confirmed in real time by the console.

Rent a Cloud Mac

Check the specs before you plan your workload

See billing periods and add-ons for Desk M4, or follow the getting-started guide to prepare for your first connection.