diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2ae359e1..3779c2ca6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index b97bcc2c4..a99c6eae5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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: shows a still picture instead), `HEVCEncoder` (VideoToolbox with one frame inside, a @@ -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 diff --git a/Sources/SillHost/InstalledApps.swift b/Sources/SillHost/InstalledApps.swift new file mode 100644 index 000000000..eb9973ccd --- /dev/null +++ b/Sources/SillHost/InstalledApps.swift @@ -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 = [] + 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 + } +} diff --git a/Sources/SillHost/WindowCatalog.swift b/Sources/SillHost/WindowCatalog.swift index 09d2f71e1..739e84a67 100644 --- a/Sources/SillHost/WindowCatalog.swift +++ b/Sources/SillHost/WindowCatalog.swift @@ -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 = [] - 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) diff --git a/Tests/checks/README.md b/Tests/checks/README.md index 438a4678d..e09a28856 100644 --- a/Tests/checks/README.md +++ b/Tests/checks/README.md @@ -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 | @@ -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. diff --git a/Tests/checks/installed-apps/main.swift b/Tests/checks/installed-apps/main.swift new file mode 100644 index 000000000..acddf74db --- /dev/null +++ b/Tests/checks/installed-apps/main.swift @@ -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") diff --git a/Tests/checks/installed-apps/mutants.py b/Tests/checks/installed-apps/mutants.py new file mode 100644 index 000000000..16da069b4 --- /dev/null +++ b/Tests/checks/installed-apps/mutants.py @@ -0,0 +1,58 @@ +"""Mutants of InstalledApps.swift (#76): each changes it in one place, is compiled with the check and +must make it fail. A link to a folder is refused by contentsOfDirectory(at:) itself (ENOTDIR), so +dropping the isDirectory test changes nothing and is no mutant here. The unsorted mutant counts on +APFS listing a folder in its own order (by a hash of each name), which puts the fixture's "right" +before "left"; a file system that lists names sorted would miss it. usage: mutants.py WORKTREE""" +import os, subprocess, sys, tempfile +WT = sys.argv[1]; HERE = os.path.dirname(os.path.abspath(__file__)) +F = os.path.join(WT, "Sources/SillHost/InstalledApps.swift") +MUTANTS = [ + ("one level down only (the rule before #135's depth)", + " level = next\n", " level = level == [dir] ? next : []\n"), + ("no folder searched (the bug of #76)", + " level = next\n", " level = []\n"), + ("hidden-flagged names skipped (Safari's link)", + "includingPropertiesForKeys: keys, options: [])", "includingPropertiesForKeys: keys, options: [.skipsHiddenFiles])"), + ("a package of another kind searched", + "values.isDirectory == true, values.isPackage != true", "values.isDirectory == true"), + ("a framework searched (a bundle that is no package)", + 'url.pathExtension != "framework", ', ""), + ("dot names kept", + 'entries.filter { !$0.lastPathComponent.hasPrefix(".") }', "entries.filter { _ in true }"), + ("the deeper bundle wins", + " return found\n", " return found.reversed()\n"), + ("each folder's entries left unsorted (APFS lists \"right\" before \"left\")", + ".sorted { $0.lastPathComponent < $1.lastPathComponent }", ""), + ("the next level walked backwards", + " level = next\n", " level = next.reversed()\n"), + ("a folder given as a link not followed", + "URL(fileURLWithPath: dir).resolvingSymlinksInPath()", "URL(fileURLWithPath: dir)"), + ("two bundles with one ID both listed", + ", !seen.contains(id) else", " else"), + ("sorted backwards", + "== .orderedAscending }\n }", "== .orderedDescending }\n }"), +] +caught = 0 +for name, old, new in MUTANTS: + src = open(F).read() + if src.count(old) != 1: + print(f"{name}: NOT APPLIED (pattern found {src.count(old)} times)"); continue + with tempfile.TemporaryDirectory() as t: + mf = os.path.join(t, "InstalledApps.swift"); open(mf, "w").write(src.replace(old, new, 1)) + exe = os.path.join(t, "c") + subprocess.run(["swiftc", "-O", mf, os.path.join(HERE, "main.swift"), "-o", exe], capture_output=True, text=True) + if not os.path.exists(exe): + print(f"{name}: did not compile (caught)"); caught += 1; continue + try: + r = subprocess.run([exe], capture_output=True, text=True, timeout=60) + code, out = r.returncode, r.stdout + except subprocess.TimeoutExpired: + code, out = -1, "FAIL timed out" + if code != 0: + caught += 1 + failed = [l for l in out.splitlines() if l.startswith("FAIL")] + print(f"{name}: caught ({failed[0][:90] if failed else f'exit {code}'})") + else: + print(f"{name}: NOT CAUGHT") +print(f"mutants caught: {caught} of {len(MUTANTS)}") +sys.exit(0 if caught == len(MUTANTS) else 1) diff --git a/Tests/checks/installed-apps/run.sh b/Tests/checks/installed-apps/run.sh new file mode 100755 index 000000000..40b6979dd --- /dev/null +++ b/Tests/checks/installed-apps/run.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# InstalledApps (Sources/SillHost/InstalledApps.swift), where the Apps list finds the Mac's apps: a +# fake applications folder in a temporary directory, with apps in a suite's folder (#76) and two +# folders down (#135), a helper inside a bundle, a package of another kind, a framework, dot names, +# symbolic links (a folder's, not followed; a folder given that is a link, followed) and bundles +# that share an ID (at different depths, and twice at one depth). +# Tests/checks/installed-apps/run.sh compile and run +# Tests/checks/installed-apps/run.sh --mutants one-line mutants of InstalledApps.swift; each must fail it +here="$(cd "$(dirname "$0")" && pwd)" +source "$here/../common.sh" +if [ "${1:-}" = "--mutants" ]; then run_mutants python3 "$here/mutants.py" "$root"; exit; fi +swiftc -O Sources/SillHost/InstalledApps.swift "$here/main.swift" -o "$out/check" +"$out/check"