Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ jobs:
strategy:
fail-fast: false
matrix:
check: [addresses, ask-limits, away-copy, away-quality, away-wire, cable-link, clientlink, clipboard, compatibility, device-gate, dmg-layout, door-policy, duo, encoder-mailbox, encoder-slowstate, fence, gesture-chords, gestures, goodbye, hardware-keyboard, home-device, home-model, home-records, home-txt, key-strokes, keychain, link-judge, menu-state, menus, message-reader, origin, pairing-address, phone-portrait, pointer-control, pointer-presence, pointer-watch, policy, protocol, remote-rules, settings-window, tour, update-policy, window-restore]
check: [addresses, ask-limits, away-copy, away-quality, away-wire, cable-link, clientlink, clipboard, compatibility, device-gate, dmg-layout, door-policy, duo, encoder-mailbox, encoder-slowstate, fence, gesture-chords, gestures, goodbye, hardware-keyboard, home-device, home-model, home-records, home-txt, installed-apps, key-strokes, keychain, link-judge, menu-state, menus, message-reader, origin, pairing-address, phone-portrait, pointer-control, pointer-presence, pointer-watch, policy, protocol, remote-rules, settings-window, tour, update-policy, window-restore]
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down
9 changes: 7 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4969,7 +4969,11 @@ good.
set, `HostConfig` ↔ wire), `HostLog` (the print shadow, the app's ring and
log file),
`WindowCatalog` (polls windows and thumbnails only while a client is
connected; icons; installed apps in the background),
connected; icons; installed apps in the background), `InstalledApps`
(where those are found: each applications folder and the folders inside it,
at any depth (#135's), never inside a bundle, another package or a
framework, and never through a link inside a scanned folder (one given that
is itself a link is followed), #76; pure, `Tests/checks/installed-apps`),
`WindowCapture` (ScreenCaptureKit), `SyntheticCapture` (test pattern for
`--synthetic`; SILL_TEST_PATTERN=noise adds a square of noise,
SILL_TEST_PATTERN=picture:<path> shows a still picture instead), `HEVCEncoder` (VideoToolbox with one frame inside, a
Expand Down Expand Up @@ -5534,7 +5538,8 @@ good.
`tour` and `phone-portrait` take the Duo's real sizes too), `encoder-mailbox`,
`encoder-slowstate`, `fence`, `gesture-chords`, `gestures`, `goodbye`,
`hardware-keyboard` (iOSClient's HardwareKeyboardPolicy),
`home-device`, `home-model`, `home-records`, `home-txt`, `key-strokes` (the
`home-device`, `home-model`, `home-records`, `home-txt`, `installed-apps`
(InstalledApps on a fake applications folder), `key-strokes` (the
host's KeyStrokes and the device's KeyChords, against a model of the Mac's
HID state table and of a Mac from before), `ledger`,
`menu-state` (iOSClient's MacMenuState), `menus` (the host's MenuFormat and
Expand Down
59 changes: 59 additions & 0 deletions Sources/SillHost/InstalledApps.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import Foundation

/// Where the Apps list finds the Mac's apps: the bundles in each applications folder and in the
/// folders inside it, at any depth. Suites install each app in a folder of its own
/// (/Applications/Adobe Photoshop 2026/Adobe Photoshop 2026.app), and the list missed every one of
/// them (#76); an app further down, like #135's /Applications/Adobe/Creative Suite/Photo.app, is
/// found too. It never looks inside a bundle, another package or a framework, so an app's own
/// helpers stay out, and never follows a symbolic link it finds in a folder, so a link back up
/// cannot loop (a folder it is given may itself be a link: see `find`).
///
/// Threading: FileManager and Bundle only, so any thread.
enum InstalledApps {
/// Sorted by display name; when two bundles share an ID, the first folder's wins, and within a
/// folder the shallower one, then the one whose folders come first by name.
static func find(in dirs: [String]) -> [(name: String, bundleID: String, path: String)] {
var seen: Set<String> = []
var apps: [(name: String, bundleID: String, path: String)] = []
for dir in dirs {
// The folder itself may be a link (~/Applications on another disk, say): it is followed, as
// before; the links inside it are not.
for url in bundles(in: URL(fileURLWithPath: dir).resolvingSymlinksInPath()) {
guard let bundle = Bundle(url: url), let id = bundle.bundleIdentifier, !seen.contains(id) else { continue }
seen.insert(id)
var display = FileManager.default.displayName(atPath: url.path)
if display.hasSuffix(".app") { display.removeLast(4) }
apps.append((display, id, url.path))
}
}
return apps.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}

/// The .app bundles in `dir` and in its folders at any depth, a level at a time (the bundles
/// directly in `dir` first), by name within each folder.
static func bundles(in dir: URL) -> [URL] {
let fm = FileManager.default
let keys: [URLResourceKey] = [.isDirectoryKey, .isPackageKey]
var found: [URL] = []
var level = [dir]
while !level.isEmpty {
var next: [URL] = []
for folder in level {
// Dot names only: macOS flags /Applications/Safari.app, a link into its cryptex, as hidden.
guard let entries = try? fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: keys, options: [])
else { continue }
let sorted = entries.filter { !$0.lastPathComponent.hasPrefix(".") }.sorted { $0.lastPathComponent < $1.lastPathComponent }
for url in sorted {
if url.pathExtension == "app" { found.append(url); continue }
// A plain folder only: not a symbolic link, not a package of another kind, and not a
// framework, a bundle that is no package (Sparkle's keeps an Updater.app inside).
guard url.pathExtension != "framework", let values = try? url.resourceValues(forKeys: Set(keys)),
values.isDirectory == true, values.isPackage != true else { continue }
next.append(url)
}
}
level = next
}
return found
}
}
20 changes: 4 additions & 16 deletions Sources/SillHost/WindowCatalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -351,22 +351,10 @@ final class WindowCatalog {
/// Sorted by display name, first bundle ID wins. Thread-safe (FileManager + Bundle only).
nonisolated private static func findInstalledApps() -> [(name: String, bundleID: String, path: String)] {
let home = FileManager.default.homeDirectoryForCurrentUser
let dirs = ["/Applications", "/Applications/Utilities", "/System/Applications", "/System/Applications/Utilities",
home.appendingPathComponent("Applications").path]
var seen: Set<String> = []
var apps: [(name: String, bundleID: String, path: String)] = []
for dir in dirs {
guard let names = try? FileManager.default.contentsOfDirectory(atPath: dir) else { continue }
for name in names where name.hasSuffix(".app") {
let url = URL(fileURLWithPath: dir).appendingPathComponent(name)
guard let bundle = Bundle(url: url), let id = bundle.bundleIdentifier, !seen.contains(id) else { continue }
seen.insert(id)
var display = FileManager.default.displayName(atPath: url.path)
if display.hasSuffix(".app") { display.removeLast(4) }
apps.append((display, id, url.path))
}
}
return apps.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
// Both Utilities folders are read twice, as a folder inside /Applications or /System/Applications
// and as given: harmless, since the first bundle with an ID wins and the second read finds the same ones.
return InstalledApps.find(in: ["/Applications", "/Applications/Utilities", "/System/Applications",
"/System/Applications/Utilities", home.appendingPathComponent("Applications").path])
}

// MARK: Image encoding (thread-safe)
Expand Down
4 changes: 3 additions & 1 deletion Tests/checks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ exit status is the number of checks that failed. Binaries, data and logs go to
| `home-model` | `iOSClient/DiscoveryPolicy.swift`, `SavedMacs.swift`, `GoodbyePolicy.swift` and `Sources/SillHost/PairingWindow.swift` with `Sources/StreamProtocol` (`module.txt`) | the home rules against the wire's own values (its strings, a real kind 20, a real TXT tag), then pairing at home walked end to end as StreamClient chains the rules, a look-alike under a saved Mac's name included, a real signed kind 18 replayed on another Mac's connection, and the reconnect meeting a door that now asks devices to pair; with `PairingWindow.swift`, the card's words against the Mac's quiet rule | 62 | 21 |
| `home-records` | `Sources/SillHost/HostIdentity.swift`, `CableLink.swift` and `OriginPolicy.swift` and `iOSClient/SavedMacs.swift` with `Sources/StreamProtocol` (`module.txt`, `-package-name sill`) | the records pairing at home adds to (the Mac's `cableDevice` and "cable", the device's `homeTLS` and `revoked`): older records decode, lists encode byte for byte as before, an older reader reads the new ones; Require pairing's stored record, off only with the Mac's own signature | 39 | 21 |
| `home-txt` | `Sources/StreamProtocol/*.swift` (`module.txt`) | pairing at home's wire values: the TXT record's `p` (HomeDoorTXT), kinds 19, 20 and 22 as the plan shows them on the wire, kind 20's `message` and the reasons a device words itself, the RemoteTLS parameters for the home door | 49 | 24 |
| `installed-apps` | `Sources/SillHost/InstalledApps.swift` | where the Apps list finds the Mac's apps (#76): a fake applications folder in a temporary directory, with apps directly in it, in a suite's folder and two folders down (#135's depth), a helper inside a bundle, a package of another kind, a framework (a bundle that is no package), dot names at any depth, a link to a folder (not followed, a link back up included) and a folder given that is itself a link (followed, as ~/Applications can be), Safari's hidden-flagged link, bundles with one ID (the shallower wins, and at one depth the one in the folder first by name), several folders and a missing one, the sort and the name | 18 | 12 |
| `key-strokes` | `Sources/SillHost/KeyStrokes.swift` and `iOSClient/KeyChords.swift` with `Sources/StreamProtocol` (`build.sh`, `-package-name sill`) | which modifiers a device's keys leave set in the Mac's HID state table, which the next click and scroll start from: on the Mac's side, a shortcut sent as one key down and up (the Spotlight key's ⌘Space from an older device) leaves none, a modifier's own key goes up without its flag, a device's own word on its modifiers lets go of the keys it no longer holds, a device that leaves and the host that goes let go of theirs, and a trackpad gesture's shortcut goes up with the table's flags from before it; the check a quarter of a second after each key's up (`KeyUpCheck`), armed for every path and reporting on a Mac that keeps its table through a modifier key's up or another key's; input with nowhere to land (`DroppedInput`) dropped but for the up of a key or a button the Mac has down; on the device's, a shortcut goes out with its modifiers' own keys around it and a hardware key's up follows its down, leaving nothing held on this Mac or on one from before, and Esc through its key command (#67) goes down and up once whichever of the two channels delivers it (the press first, the command first, a repeat, a swallowed press's up, ⇧Esc and ⌘Esc); the exact events, each latched combination, and random sessions (two devices from before, with gestures, 3,000 on a Mac as the model says and 1,000 on each Mac keeping its table; this device alone, Esc's key command among its keys, 2,000 and 500 each; 2,000 of input placed or not) against the invariant and the check's | 347 | 81 |
| `keychain` | `Sources/SillMenuBar/IdentityStorePlan.swift` | which identity store the menu-bar host uses at launch (docs/keychain-plan.md §4): the data-protection keychain only for an entitled real Sill.app and never the legacy one beside it, the legacy login keychain for an unentitled real Sill.app, memory or a test directory for a test host | 13 | 7 |
| `ledger` | `iOSClient/HostSettingsLedger.swift`, `Sources/StreamProtocol/HostSettings.swift` | the device's settings ledger against a model host, scenarios and 5,000 random runs | 90 | none |
Expand Down Expand Up @@ -89,7 +90,8 @@ Before it: the `home-pairing` branch's merge with main at da43f6b ran the mutant
that compile the whole of StreamProtocol (docs/home-pairing-plan.md, Results), `key-strokes`' count
is the `hardware-keyboard` branch's (#67, #68: Esc's key command; the `spotlight-modifier-fix` branch's
before it), `hardware-keyboard` came with that branch, `home-device`, `home-model` and
`remote-rules` grew with #44 and #46, and `policy` with #69 (`sleep.swift`).
`remote-rules` grew with #44 and #46, `policy` with #69 (`sleep.swift`), and `installed-apps` came
with #136 (#76), its depth #135's.

A mutant changes the checked file in one place and must make the check fail: `run.sh --mutants`
(or `run-all.sh --mutants`) passes only when the script's last line counts every mutant as caught.
Expand Down
82 changes: 82 additions & 0 deletions Tests/checks/installed-apps/main.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// InstalledApps (Sources/SillHost/InstalledApps.swift) on a fake applications folder:
//
// swiftc -O Sources/SillHost/InstalledApps.swift Tests/checks/installed-apps/main.swift \
// -o .build/checks/installed-apps/check && .build/checks/installed-apps/check
import Foundation

var failures = 0
func check(_ ok: Bool, _ what: String) { print((ok ? "ok " : "FAIL ") + what); if !ok { failures += 1 } }

let fm = FileManager.default
let root = fm.temporaryDirectory.appendingPathComponent("installed-apps-\(UUID().uuidString)")
defer { try? fm.removeItem(at: root) }

/// A bundle Bundle(url:) reads: Contents/Info.plist with an identifier.
func app(_ path: String, id: String) {
let contents = root.appendingPathComponent(path).appendingPathComponent("Contents")
try! fm.createDirectory(at: contents, withIntermediateDirectories: true)
let plist: [String: Any] = ["CFBundleIdentifier": id, "CFBundlePackageType": "APPL"]
try! PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
.write(to: contents.appendingPathComponent("Info.plist"))
}

app("Applications/Zed.app", id: "dev.zed")
app("Applications/Adobe Photoshop 2026/Adobe Photoshop 2026.app", id: "com.adobe.Photoshop")
app("Applications/Adobe Photoshop 2026/Uninstall Adobe Photoshop 2026.app", id: "com.adobe.uninstall")
app("Applications/Adobe/Creative Suite/Photo.app", id: "two.down")
app("Applications/Copy/Deeper/Zed.app", id: "dev.zed")
app("Applications/Thing.bundle/Inner.app", id: "in.package")
app("Applications/Suite/.Helper.app", id: "dot.app")
// Sparkle's framework keeps an Updater.app inside, and a framework is a bundle that is no package.
app("Applications/Suite/Sparkle.framework/Versions/B/Updater.app", id: "in.framework")
// One app twice at one depth, in sibling folders that APFS lists out of order ("right" first).
app("Applications/Twins/right/Twin.app", id: "twin")
app("Applications/Twins/left/Twin.app", id: "twin")
app("Applications/Zed.app/Contents/Helpers/Zed Helper.app", id: "dev.zed.helper")
app("Applications/.hidden/Secret.app", id: "hidden.app")
app("Applications/Copy/Zed.app", id: "dev.zed")
app("Elsewhere/Linked.app", id: "linked.app")
try! fm.createSymbolicLink(at: root.appendingPathComponent("Applications/Loop"), withDestinationURL: root.appendingPathComponent("Applications"))
try! fm.createSymbolicLink(at: root.appendingPathComponent("Applications/Linked"),
withDestinationURL: root.appendingPathComponent("Elsewhere"))
app("More/Alpha.app", id: "alpha")
// Like /Applications/Safari.app: a link to a bundle elsewhere, flagged hidden.
app("Cryptex/Safari.app", id: "com.apple.Safari")
let safari = root.appendingPathComponent("Applications/Safari.app")
try! fm.createSymbolicLink(at: safari, withDestinationURL: root.appendingPathComponent("Cryptex/Safari.app"))
_ = safari.path.withCString { lchflags($0, UInt32(UF_HIDDEN)) }

app("Volume/Apps/Beta.app", id: "beta")
try! fm.createSymbolicLink(at: root.appendingPathComponent("UserApps"), withDestinationURL: root.appendingPathComponent("Volume/Apps"))
let dirs = [root.appendingPathComponent("Applications").path, root.appendingPathComponent("More").path,
root.appendingPathComponent("Missing").path, root.appendingPathComponent("UserApps").path]
let found = InstalledApps.find(in: dirs)
let ids = found.map(\.bundleID)
print(found.map { "\($0.name) (\($0.bundleID))" }.joined(separator: ", "))

check(ids.contains("dev.zed"), "an app directly in the folder is listed")
check(ids.contains("com.adobe.Photoshop") && ids.contains("com.adobe.uninstall"),
"apps in a suite's folder one level down are listed (#76)")
check(ids.contains("two.down"), "an app two folders down is listed (#135's /Applications/Adobe/Creative Suite/Photo.app)")
check(!ids.contains("in.package"), "nothing inside a package of another kind is listed")
check(!ids.contains("in.framework"), "nothing inside a framework is listed")
check(!ids.contains("dot.app"), "a bundle whose name starts with a dot is skipped, at any depth")
check(!ids.contains("dev.zed.helper"), "nothing inside a bundle is listed")
check(!ids.contains("hidden.app"), "a folder whose name starts with a dot is skipped")
check(ids.contains("com.apple.Safari"), "a link to an app, flagged hidden like Safari's, is listed")
check(!ids.contains("linked.app"), "a symbolic link to a folder is not followed")
check(ids.filter { $0 == "dev.zed" }.count == 1, "two bundles with one ID are listed once")
check(found.first { $0.bundleID == "dev.zed" }?.path.hasSuffix("Applications/Zed.app") == true,
"of two bundles with one ID, the one directly in the folder wins")
check(found.first { $0.bundleID == "twin" }?.path.hasSuffix("Twins/left/Twin.app") == true,
"of two bundles with one ID at one depth, the one in the folder first by name wins")
check(ids.contains("alpha"), "every folder given is searched; a missing one is skipped")
check(ids.contains("beta"), "a folder given that is itself a link, as ~/Applications can be, is searched")
check(found.map(\.name) == found.map(\.name).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending },
"sorted by display name")
check(found.first { $0.bundleID == "com.adobe.Photoshop" }?.name == "Adobe Photoshop 2026", "the name drops .app")
check(InstalledApps.find(in: [root.appendingPathComponent("Missing").path]).isEmpty, "a missing folder gives nothing")
// exit() skips the top-level defer, so the fake folder goes first.
try? fm.removeItem(at: root)
if failures > 0 { print("installed-apps: \(failures) failed"); exit(1) }
print("installed-apps: every check passed")
Loading
Loading